mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-31 01:10:41 +01:00
* feat(s3/lifecycle): cluster rate-limit allocation (Phase 3)
Admin computes a per-worker share of cluster_deletes_per_second at
ExecuteJob time and ships it to the worker via
ClusterContext.Metadata. The worker reads the share, constructs a
golang.org/x/time/rate.Limiter, and passes it to dailyrun.Run via
cfg.Limiter (Phase 2 already plumbed the field). Phase 5 deletes the
streaming path; until then streaming ignores the cap.
Why allocate at admin: the cluster cap is a single knob operators
care about. Dividing it locally per worker would either need
out-of-band coordination or accept N× the configured budget. Admin
is the only party that knows how many execute-capable workers there
are, so it owns the math.
Admin side (weed/admin/plugin):
- Registry.CountCapableExecutors(jobType) returns the number of
non-stale workers with CanExecute=true.
- New file cluster_rate_limit.go: decorateClusterContextForJob clones
the input ClusterContext and injects two metadata keys for
s3_lifecycle. cloneClusterContext duplicates Metadata so per-job
decoration doesn't race shared base state.
- executeJobWithExecutor calls the decorator after loading the admin
config; other job types pass through unchanged.
Worker side (weed/worker/tasks/s3_lifecycle):
- New cluster_rate_limit.go declares the constants both sides agree
on (admin-config field names, metadata keys). Plain strings on the
admin side keep weed/admin/plugin free of a dependency on the
s3_lifecycle worker package; the two sets of constants are pinned
to identical values and a mismatch would silently disable rate
limiting.
- handler.go executeDailyReplay reads ClusterContext.Metadata,
builds a rate.Limiter, and passes it into dailyrun.Config{Limiter}.
Missing/empty/non-positive values → no limiter (legacy unlimited
behavior). burst defaults to 2 × rate, clamped to ≥1 to avoid a
bucket that never refills.
- Admin form gains two fields under "Scope": cluster_deletes_per_second
(rate, 0 = unlimited) and cluster_deletes_burst (0 = 2 × rate).
Metric:
- New S3LifecycleDispatchLimiterWaitSeconds histogram observes how
long each Limiter.Wait blocks before a LifecycleDelete RPC.
Operators tune the cap by reading p95 — near-zero means the cap
isn't binding, a long tail at 1/rate means it is.
Tests:
- weed/admin/plugin/cluster_rate_limit_test.go: 9 cases covering
pass-through for non-allocator job types, rps=0 / no-executors
skip, even sharing, burst sharing, burst=0 omit (worker default
kicks in), burst floor of 1, no mutation of input metadata, nil
input.
- weed/worker/tasks/s3_lifecycle/cluster_rate_limit_test.go: 7 cases
covering nil/empty/missing metadata, non-positive/invalid rate,
positive rate builds correctly, burst missing defaults to 2× rate,
tiny rate clamps burst to ≥1.
Build clean. Phase 2 (#9446) and Phase 4 engine (#9447) are the
parents; this branch stacks on Phase 2 since it consumes
dailyrun.Config{Limiter} which lands there.
* fix(s3/lifecycle): divide cluster budget by active workers, not all capable
gemini pointed out that s3_lifecycle has MaxJobsPerDetection=1
(handler.go:189) — it's a singleton job, only one worker is ever active.
Dividing the cluster_deletes_per_second budget by the count of capable
executors gave the single active worker just 1/N of the configured cap.
Pass adminRuntime.MaxJobsPerDetection through to the decorator. Divisor
is now min(executors, maxJobsPerDetection), clamped to >=1. For
s3_lifecycle (maxJobs=1) the active worker gets the full budget; for a
hypothetical parallel-dispatch job (maxJobs>1) the budget divides
across the running-set.
Tests swap the SharedEvenly case for two pinned scenarios:
- SingletonJobGetsFullBudget: maxJobs=1 across 4 executors => 100/1
- SharedEvenlyWhenParallelLimited: maxJobs=4 across 4 executors => 25/worker
- MaxJobsExceedsExecutors: maxJobs=10 across 4 executors => divisor 4
* feat(s3/lifecycle): drop Worker Count knob from admin config form
The "Worker Count" admin field controlled in-process pipeline goroutines
across the 16-shard space — per-worker tuning, not a cluster-wide scope
concern. Operators looking at the form alongside Cluster Delete Rate
reasonably misread it as the number of workers in the cluster.
Drop the form field and DefaultValues entry. cfg.Workers is now hardcoded
to shardPipelineGoroutines (=1) inside ParseConfig; the rest of the
plumbing through dailyrun.Config.Workers stays so a future need can
re-introduce it as a worker-local knob (or just bump the constant).
handler_test.go pins that "workers" must NOT appear in the form so the
removal doesn't silently regress.
439 lines
16 KiB
Go
439 lines
16 KiB
Go
package dailyrun
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/s3_lifecycle_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/engine"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/reader"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/router"
|
|
"github.com/seaweedfs/seaweedfs/weed/stats"
|
|
"github.com/seaweedfs/seaweedfs/weed/util"
|
|
"golang.org/x/time/rate"
|
|
)
|
|
|
|
// LifecycleClient is the same RPC contract the streaming dispatcher
|
|
// uses. Replicated here to avoid an import cycle with dispatcher; both
|
|
// shapes target s3_lifecycle_pb.SeaweedS3LifecycleInternalClient and
|
|
// neither owns the protobuf interface.
|
|
type LifecycleClient interface {
|
|
LifecycleDelete(ctx context.Context, req *s3_lifecycle_pb.LifecycleDeleteRequest) (*s3_lifecycle_pb.LifecycleDeleteResponse, error)
|
|
}
|
|
|
|
// Config bundles everything daily_run needs to make one shard-fan-out
|
|
// pass over the meta-log. Fields with zero values use documented
|
|
// defaults; required fields are checked at the top of Run.
|
|
type Config struct {
|
|
Shards []int
|
|
BucketsPath string
|
|
Engine *engine.Engine
|
|
FilerClient filer_pb.SeaweedFilerClient
|
|
Client LifecycleClient
|
|
Persister CursorPersister
|
|
Lister router.SiblingLister
|
|
|
|
// Workers caps how many shards run in parallel. Each shard owns its
|
|
// own meta-log subscription and rate limiter is shared across all
|
|
// of them, so the cap is about filer-side concurrency rather than
|
|
// throughput. Zero or negative → 1 (serial, the legacy default).
|
|
Workers int
|
|
|
|
// Limiter is the optional per-worker rate.Limiter shared across all
|
|
// shard goroutines on this worker. nil = no rate limit (the legacy
|
|
// "drain as fast as the filer can" behavior). Phase 3 wires the
|
|
// per-worker share from ClusterContext.Metadata into this field.
|
|
Limiter *rate.Limiter
|
|
|
|
// ClientName / ClientID identify this worker on the meta-log
|
|
// subscription. ClientID 0 randomizes per-run.
|
|
ClientName string
|
|
ClientID int32
|
|
|
|
// Now overrides time.Now for tests. nil = production wall clock.
|
|
Now func() time.Time
|
|
|
|
// EventBudget caps how many meta-log events each shard's reader
|
|
// consumes before returning. Zero = unbounded (the run-until-now
|
|
// behavior — see "stop condition" below). Tests set a small value
|
|
// to bound deterministic runs.
|
|
EventBudget int
|
|
}
|
|
|
|
// Run executes the daily replay for every shard in cfg.Shards
|
|
// concurrently. Returns the first non-nil error from any shard; other
|
|
// shards still run to completion so a transient filer error on one
|
|
// shard doesn't lose progress on the others.
|
|
//
|
|
// Phase 2 scope: replay only. A bucket whose compiled rules require
|
|
// walker-bound dispatch (ExpirationDate, ExpiredDeleteMarker,
|
|
// NewerNoncurrent) or any rule promoted to scan_only fails the whole
|
|
// run with an UnsupportedRuleError — the handler reports this so admin
|
|
// can either revert algorithm=streaming or wait for Phase 4.
|
|
func Run(ctx context.Context, cfg Config) error {
|
|
if err := validate(cfg); err != nil {
|
|
return err
|
|
}
|
|
now := cfg.Now
|
|
if now == nil {
|
|
now = func() time.Time { return time.Now().UTC() }
|
|
}
|
|
// Freeze "now" at the start of the run. Every shard, every match's
|
|
// DueTime comparison, and every cursor-floor calculation uses the
|
|
// same instant so a long-running shard doesn't see the boundary
|
|
// drift relative to a short-running peer.
|
|
runNow := now()
|
|
|
|
// Refuse runs whose snapshot includes a walker-bound action kind
|
|
// or any rule the router won't route. Done once against an
|
|
// immutable snapshot — every shard reuses this exact value so a
|
|
// mid-execution Compile can't make shards disagree about the rule
|
|
// set or the hash.
|
|
snap := cfg.Engine.Snapshot()
|
|
if unsupported := checkSnapshotForUnsupported(snap); unsupported != nil {
|
|
return unsupported
|
|
}
|
|
|
|
// Concurrency cap. cfg.Workers controls how many shards run in
|
|
// parallel; the rate limiter (Phase 3) governs throughput. With
|
|
// Workers=1 (the legacy default) the 16 shards process serially.
|
|
workers := cfg.Workers
|
|
if workers <= 0 {
|
|
workers = 1
|
|
}
|
|
if workers > len(cfg.Shards) {
|
|
workers = len(cfg.Shards)
|
|
}
|
|
sem := make(chan struct{}, workers)
|
|
|
|
var wg sync.WaitGroup
|
|
errCh := make(chan error, len(cfg.Shards))
|
|
for _, sh := range cfg.Shards {
|
|
sh := sh
|
|
wg.Add(1)
|
|
sem <- struct{}{}
|
|
go func() {
|
|
defer wg.Done()
|
|
defer func() { <-sem }()
|
|
if err := runShard(ctx, cfg, snap, runNow, sh); err != nil {
|
|
errCh <- err
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
close(errCh)
|
|
// Return the first error; the rest are logged at glog level so
|
|
// they're recoverable in the operational stream.
|
|
var first error
|
|
for err := range errCh {
|
|
if first == nil {
|
|
first = err
|
|
} else {
|
|
glog.V(1).Infof("daily_run: additional shard error: %v", err)
|
|
}
|
|
}
|
|
return first
|
|
}
|
|
|
|
func validate(cfg Config) error {
|
|
if cfg.Engine == nil {
|
|
return errors.New("daily_run: nil Engine")
|
|
}
|
|
if cfg.FilerClient == nil {
|
|
return errors.New("daily_run: nil FilerClient")
|
|
}
|
|
if cfg.Client == nil {
|
|
return errors.New("daily_run: nil Client (LifecycleDelete RPC)")
|
|
}
|
|
if cfg.Persister == nil {
|
|
return errors.New("daily_run: nil Persister")
|
|
}
|
|
if cfg.Lister == nil {
|
|
return errors.New("daily_run: nil Lister")
|
|
}
|
|
if cfg.BucketsPath == "" {
|
|
return errors.New("daily_run: empty BucketsPath")
|
|
}
|
|
for _, sh := range cfg.Shards {
|
|
if sh < 0 || sh >= s3lifecycle.ShardCount {
|
|
return fmt.Errorf("daily_run: shard %d out of [0, %d)", sh, s3lifecycle.ShardCount)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// runShard executes the daily replay loop for a single shard. The
|
|
// algorithm is documented in DESIGN.md ("Algorithm" section).
|
|
//
|
|
// Phase 2 omissions vs. the full design:
|
|
// - No walker invocation on rule-change / cold-start / retention
|
|
// loss. The cursor is rewritten and the worker exits; Phase 4
|
|
// wires the walker over engine.RecoveryView(snap) on these
|
|
// branches.
|
|
// - PromotedHash is always the empty hash. The partition-flip
|
|
// trigger is dormant until Phase 4.
|
|
// - All walker-bound action kinds and scan_only-promoted rules are
|
|
// refused at validate-time (see checkSnapshotForUnsupported), so
|
|
// replay only ever sees ExpirationDays / NoncurrentDays / AbortMPU.
|
|
//
|
|
// snap is the engine snapshot captured at the top of Run; reusing the
|
|
// same value across shards guarantees they observe identical rules and
|
|
// hashes. runNow is the frozen instant for due-time comparisons.
|
|
func runShard(ctx context.Context, cfg Config, snap *engine.Snapshot, runNow time.Time, shardID int) error {
|
|
persisted, found, err := cfg.Persister.Load(ctx, shardID)
|
|
if err != nil {
|
|
return fmt.Errorf("shard=%d: load cursor: %w", shardID, err)
|
|
}
|
|
|
|
rsh := localReplayContentHash(snap)
|
|
maxTTL := localMaxEffectiveTTL(snap)
|
|
|
|
// Empty-replay sentinel: no replay-eligible active rules in the
|
|
// snapshot. We persist the hash so a future rule addition is
|
|
// detected as a content change. Use the hash rather than maxTTL
|
|
// here as the explicit "no replay state" signal — both fire on
|
|
// the same set in practice (action_kind.go only emits actions
|
|
// when their Days field is > 0), but the hash captures intent.
|
|
if rsh == [32]byte{} {
|
|
return cfg.Persister.Save(ctx, shardID, Cursor{
|
|
TsNs: 0,
|
|
RuleSetHash: rsh,
|
|
PromotedHash: [32]byte{}, // Phase 2: always empty
|
|
})
|
|
}
|
|
|
|
// Rule-change branch: hash mismatch (and a persisted cursor exists)
|
|
// rewinds to now - max_ttl. Phase 4 adds the walker call here.
|
|
if found && persisted.RuleSetHash != rsh {
|
|
next := Cursor{
|
|
TsNs: runNow.Add(-maxTTL).UnixNano(),
|
|
RuleSetHash: rsh,
|
|
// PromotedHash stays empty in Phase 2.
|
|
}
|
|
return cfg.Persister.Save(ctx, shardID, next)
|
|
}
|
|
|
|
// Cold start with no prior cursor: start scanning from now - max_ttl
|
|
// so a freshly-installed worker still expires already-due objects
|
|
// whose PUT events sit within meta-log retention. Phase 4 would
|
|
// invoke the walker here for the longer-than-retention case; Phase 2
|
|
// trusts that retention >= max_ttl in the deployments this code is
|
|
// enabled on.
|
|
startTsNs := persisted.TsNs
|
|
floor := runNow.Add(-maxTTL).UnixNano()
|
|
if startTsNs < floor {
|
|
startTsNs = floor
|
|
}
|
|
|
|
lastOK, _, drainErr := drainShardEvents(ctx, cfg, runNow, shardID, snap, startTsNs)
|
|
if drainErr != nil {
|
|
// Drain failed (transport, ctx cancel, or limiter shutdown):
|
|
// persist whatever progress we have so subsequent runs don't
|
|
// re-process the events we already dispatched, then propagate
|
|
// the error. Cursor save uses ctx — if ctx is already cancelled
|
|
// the save will fail too; that's fine, we still surface the
|
|
// underlying drain error.
|
|
_ = cfg.Persister.Save(ctx, shardID, Cursor{TsNs: lastOK, RuleSetHash: rsh})
|
|
return fmt.Errorf("shard=%d: drain: %w", shardID, drainErr)
|
|
}
|
|
|
|
// drainShardEvents stops advancing lastOK at the first event with a
|
|
// skipped (not-yet-due) match. Persisting that value means the next
|
|
// run resumes from before the skipped event and re-scans it —
|
|
// without that, future-DueTime matches would be lost. halted=true
|
|
// adds nothing here because lastOK already reflects the
|
|
// stuck-at-failure boundary.
|
|
return cfg.Persister.Save(ctx, shardID, Cursor{
|
|
TsNs: lastOK,
|
|
RuleSetHash: rsh,
|
|
// PromotedHash stays empty in Phase 2.
|
|
})
|
|
}
|
|
|
|
// drainShardEvents subscribes to the meta-log starting at startTsNs,
|
|
// routes every event through router.Route, and dispatches each Match
|
|
// whose due_time is past runNow. Returns (cursorAdvanceTo, halted, error):
|
|
// - cursorAdvanceTo: the highest TsNs the persisted cursor may safely
|
|
// advance to. Equals the TsNs of the last event whose matches were
|
|
// ALL dispatched (DONE/NOOP_RESOLVED/SKIPPED_OBJECT_LOCK) AND every
|
|
// prior event was likewise fully processed. Once an event with a
|
|
// not-yet-due match is encountered, cursorAdvanceTo stops growing —
|
|
// so subsequent runs re-scan that event and any after it.
|
|
// - halted: true when an unresolved dispatch outcome (RETRY_LATER /
|
|
// BLOCKED / transport error after in-run retries) stopped the loop.
|
|
// - error: stream/setup failure or context cancellation; caller
|
|
// persists cursorAdvanceTo and propagates the error so the run is
|
|
// marked as interrupted rather than successful.
|
|
func drainShardEvents(ctx context.Context, cfg Config, runNow time.Time, shardID int, snap *engine.Snapshot, startTsNs int64) (int64, bool, error) {
|
|
clientName := cfg.ClientName
|
|
if clientName == "" {
|
|
clientName = "worker-s3-lifecycle-daily"
|
|
}
|
|
clientID := cfg.ClientID
|
|
if clientID == 0 {
|
|
clientID = int32(util.RandomInt32())
|
|
}
|
|
runUpTo := runNow.UnixNano()
|
|
if startTsNs >= runUpTo {
|
|
// Nothing to do — cursor already at or past the run boundary.
|
|
return startTsNs, false, nil
|
|
}
|
|
|
|
events := make(chan *reader.Event, 64)
|
|
rd := &reader.Reader{
|
|
ShardID: shardID,
|
|
BucketsPath: cfg.BucketsPath,
|
|
StartTsNs: startTsNs,
|
|
Events: events,
|
|
EventBudget: cfg.EventBudget,
|
|
}
|
|
|
|
readerCtx, cancelReader := context.WithCancel(ctx)
|
|
defer cancelReader()
|
|
|
|
readerDone := make(chan error, 1)
|
|
go func() {
|
|
readerDone <- rd.Run(readerCtx, cfg.FilerClient, clientName, clientID)
|
|
}()
|
|
|
|
cursorAdvanceTo := startTsNs
|
|
// stuck flips to true at the first event with a skipped (not-yet-due)
|
|
// match. From that event onward, cursorAdvanceTo no longer rises —
|
|
// future runs must re-scan everything from cursorAdvanceTo + 1 onward
|
|
// so the future-due matches get re-evaluated when they age in.
|
|
stuck := false
|
|
halted := false
|
|
|
|
drain:
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
// Parent context cancellation (worker shutdown, MaxRuntime).
|
|
// Return whatever progress was made so far and propagate the
|
|
// error so the caller marks the run as interrupted.
|
|
cancelReader()
|
|
<-readerDone
|
|
return cursorAdvanceTo, true, ctx.Err()
|
|
case ev, ok := <-events:
|
|
if !ok {
|
|
break drain
|
|
}
|
|
if ev == nil {
|
|
continue
|
|
}
|
|
if ev.TsNs > runUpTo {
|
|
// Reached the run boundary: events past now belong to
|
|
// tomorrow's pass. Cancel the reader so it doesn't keep
|
|
// pulling live events, then exit.
|
|
cancelReader()
|
|
break drain
|
|
}
|
|
matches := router.Route(ctx, snap, ev, runNow, cfg.Lister)
|
|
eventSkipped, eventHalted, eventErr := processMatches(ctx, cfg, runNow, ev, matches)
|
|
if eventErr != nil {
|
|
// A non-dispatch error (e.g. limiter wait cancelled by
|
|
// shutdown). Cursor stays at the last fully-processed
|
|
// event; surface the error so the caller treats it as
|
|
// an interrupt.
|
|
cancelReader()
|
|
<-readerDone
|
|
return cursorAdvanceTo, true, eventErr
|
|
}
|
|
if eventHalted {
|
|
halted = true
|
|
break drain
|
|
}
|
|
if eventSkipped {
|
|
// First event with a not-yet-due match. Don't advance
|
|
// the cursor past it; keep processing later events to
|
|
// dispatch any due ones, but the persisted cursor stays
|
|
// at the previous cursorAdvanceTo so this event is
|
|
// re-scanned tomorrow.
|
|
stuck = true
|
|
continue
|
|
}
|
|
if !stuck {
|
|
cursorAdvanceTo = ev.TsNs
|
|
}
|
|
}
|
|
}
|
|
|
|
// Wait for reader to drain so its goroutine doesn't outlive us.
|
|
cancelReader()
|
|
if rerr := <-readerDone; rerr != nil && !errors.Is(rerr, context.Canceled) {
|
|
glog.V(2).Infof("daily_run shard=%d: reader returned: %v", shardID, rerr)
|
|
}
|
|
return cursorAdvanceTo, halted, nil
|
|
}
|
|
|
|
// processMatches dispatches every match emitted from one event. Returns
|
|
// (skippedAny, halted, error):
|
|
// - skippedAny=true if at least one match had a DueTime past runNow.
|
|
// The caller must NOT advance the persisted cursor past this event
|
|
// so the skipped match gets re-scanned in a later run.
|
|
// - halted=true on an unresolved outcome (RETRY_LATER / BLOCKED /
|
|
// transport error after in-run retries). Caller exits the drain
|
|
// loop.
|
|
// - error: propagated from limiter.Wait when ctx is cancelled.
|
|
//
|
|
// A future-DueTime match is silently skipped — it does NOT terminate
|
|
// the loop or get cached as "done for this rule." A single event can
|
|
// produce multiple matches for the same ActionKey against different
|
|
// objects (routePointerTransitionExpand emits one match per noncurrent
|
|
// sibling, each with its own SuccessorModTime derived from a different
|
|
// demoting event), so a not-yet-due sibling must never gate a sibling
|
|
// that's already past its DueTime. Without per-object state the only
|
|
// safe behavior is per-match independence; the cursor-advance gate
|
|
// upstream handles re-scanning the skipped events tomorrow.
|
|
//
|
|
// runNow is the frozen run-start instant. Using it (rather than a
|
|
// fresh now() per call) keeps the boundary stable across all matches
|
|
// in this run.
|
|
func processMatches(ctx context.Context, cfg Config, runNow time.Time, ev *reader.Event, matches []router.Match) (skippedAny, halted bool, err error) {
|
|
for _, m := range matches {
|
|
if m.DueTime.After(runNow) {
|
|
skippedAny = true
|
|
continue
|
|
}
|
|
if cfg.Limiter != nil {
|
|
waitStart := time.Now()
|
|
if waitErr := cfg.Limiter.Wait(ctx); waitErr != nil {
|
|
return skippedAny, true, waitErr
|
|
}
|
|
stats.S3LifecycleDispatchLimiterWaitSeconds.Observe(time.Since(waitStart).Seconds())
|
|
}
|
|
outcome, dispatchErr := dispatchWithRetry(ctx, cfg.Client, m)
|
|
if dispatchErr != nil {
|
|
// Exhausted in-run transport retries: halt; tomorrow retries
|
|
// from the same cursor.
|
|
glog.V(1).Infof("daily_run: transport error on %s/%s %s: %v",
|
|
m.Bucket, m.ObjectKey, m.Key.ActionKind, dispatchErr)
|
|
return skippedAny, true, nil
|
|
}
|
|
switch outcome {
|
|
case s3_lifecycle_pb.LifecycleDeleteOutcome_DONE,
|
|
s3_lifecycle_pb.LifecycleDeleteOutcome_NOOP_RESOLVED,
|
|
s3_lifecycle_pb.LifecycleDeleteOutcome_SKIPPED_OBJECT_LOCK:
|
|
// Cursor advances. The dispatch's own metric (in the RPC
|
|
// server) records the outcome; the daily-run side stays
|
|
// metric-quiet to avoid double-counting.
|
|
case s3_lifecycle_pb.LifecycleDeleteOutcome_RETRY_LATER,
|
|
s3_lifecycle_pb.LifecycleDeleteOutcome_BLOCKED:
|
|
glog.V(1).Infof("daily_run: %s on %s/%s %s",
|
|
outcome, m.Bucket, m.ObjectKey, m.Key.ActionKind)
|
|
return skippedAny, true, nil
|
|
default:
|
|
glog.V(1).Infof("daily_run: unknown outcome %v on %s/%s", outcome, m.Bucket, m.ObjectKey)
|
|
return skippedAny, true, nil
|
|
}
|
|
_ = ev // ev kept available for future per-event logging
|
|
}
|
|
return skippedAny, false, nil
|
|
}
|