Files
seaweedfs/weed/s3api/s3lifecycle/dailyrun/run_stats.go
T
Chris LuandClaude Opus 5.5 c819e3fbea s3 lifecycle: per-pass run results and last full-pass status (#12643)
* s3 lifecycle: collect per-pass bucket results and work counters

WalkBuckets kept only the first bucket error and logged the rest. A
RunStats shared by the walker, the walker dispatcher and the replay
drain now records every bucket's walk result (walked, failed, skipped
on cancellation), entry and object visits, replay events, and
LifecycleDelete outcomes under the dispatch_total outcome labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3 lifecycle: persist the last fully successful pass

Shard cursors and walk timestamps advance on partial passes as well, so
neither says when lifecycle last covered every policy. Keep that in its
own record next to the cursors, plus a worker gauge for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3 lifecycle: report each pass's result in the job record

The job ended with a fixed success summary, or with only the first error,
and malformed lifecycle policies were V(1) logs. The completion now
carries the pass status (complete, partial, failed, canceled), bucket
totals and failures, invalid policies, entry, object and event counts,
LifecycleDelete outcomes, the last phase and bucket, the elapsed time
and the last full pass, on failed passes as well.

Only a complete pass advances the full-pass record; a malformed policy
makes the pass partial.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* admin: keep a failed job's result in its record

Completions with Success=false dropped Result.Summary and
Result.OutputValues, so a handler could not report what a failed run
had done, which buckets failed, or how far it got. Keep them; the error
message still drives the job's message and run record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* grafana: chart the time since the last full lifecycle pass

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3 lifecycle: a replay halted on an unresolved delete is a partial pass

BLOCKED, RETRY_LATER and exhausted transport retries stop a shard's
replay without an error, leaving its due events for a later pass. Count
those shards and report the pass as partial, so it no longer advances
the full-pass record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3 lifecycle: a pass with throttled walks is not a full pass

WalkerInterval can skip the walk for walk-only rules (ExpirationDate,
ExpiredDeleteMarker, NewerNoncurrent) with no error. Count those shards
and keep the full-pass record for passes that evaluated every rule; the
job reports full_pass and walks_deferred.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3 lifecycle: keep the saved full-pass time when the save fails

Publishing a timestamp that never reached the filer let the gauge move
back on the next job's reload. Keep the previous value and report the
save error in the job result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* admin: drop a failed attempt's summary when the retry reports none

Retries reuse the job record, and failed completions now keep their
result summary, so a retry that completes without a Result showed the
failed attempt's summary, and a successful one used it as its message.
Clear the summary when a completion carries no Result, as the output
values already are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 10:50:37 -08:00

222 lines
4.4 KiB
Go

package dailyrun
import (
"strings"
"sync"
"sync/atomic"
"github.com/seaweedfs/seaweedfs/weed/stats"
)
// Bounds what a summary carries per pass; the counts stay exact.
const (
maxReportedBucketFailures = 10
maxReportedErrorLen = 256
)
const (
PhaseWalk = "walk"
PhaseReplay = "replay"
)
type bucketState uint8
const (
bucketSkipped bucketState = iota + 1
bucketWalked
bucketFailed
)
type BucketFailure struct {
Bucket string
Error string
}
// RunSummary is a copy of a RunStats at one point in time. A bucket walked
// more than once in a pass counts once, as failed if any walk failed.
type RunSummary struct {
Phase string
Bucket string
BucketsWalked int
BucketsFailed int
BucketsSkipped int
// The first maxReportedBucketFailures failed buckets.
BucketFailures []BucketFailure
// Every listed entry, versions and delete markers included.
EntriesVisited int64
// Distinct object keys among the listed entries.
ObjectsVisited int64
EventsScanned int64
// LifecycleDelete outcomes keyed by the dispatch_total outcome label.
// They count RPC results, not reclaimed space.
Outcomes map[string]int64
ShardErrors int
// Shards whose replay stopped on an unresolved delete; their due
// events wait for a later pass.
ShardsHalted int
// Shards whose walk-only rules waited for WalkerInterval.
WalksDeferred int
}
// RunStats collects one pass's work. Safe for concurrent use; a nil
// *RunStats ignores every call.
type RunStats struct {
entries atomic.Int64
objects atomic.Int64
events atomic.Int64
mu sync.Mutex
phase string
bucket string
buckets map[string]bucketState
failures []BucketFailure
outcomes map[string]int64
shardErrors int
shardsHalted int
deferred int
}
func (s *RunStats) startBucket(bucket string) {
if s == nil {
return
}
s.mu.Lock()
s.phase, s.bucket = PhaseWalk, bucket
s.mu.Unlock()
}
func (s *RunStats) finishBucket(bucket string, state bucketState, err error) {
if s == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
if s.buckets == nil {
s.buckets = map[string]bucketState{}
}
prev := s.buckets[bucket]
if state <= prev {
return
}
s.buckets[bucket] = state
if state == bucketFailed && len(s.failures) < maxReportedBucketFailures {
s.failures = append(s.failures, BucketFailure{Bucket: bucket, Error: truncateError(err)})
}
}
func (s *RunStats) startReplay() {
if s == nil {
return
}
s.mu.Lock()
s.phase, s.bucket = PhaseReplay, ""
s.mu.Unlock()
}
func (s *RunStats) visitEntry(newObject bool) {
if s == nil {
return
}
s.entries.Add(1)
if newObject {
s.objects.Add(1)
}
}
func (s *RunStats) scanEvent() {
if s == nil {
return
}
s.events.Add(1)
}
func (s *RunStats) recordOutcome(outcome string) {
if s == nil {
return
}
s.mu.Lock()
if s.outcomes == nil {
s.outcomes = map[string]int64{}
}
s.outcomes[outcome]++
s.mu.Unlock()
}
func (s *RunStats) setShardErrors(n int) {
if s == nil {
return
}
s.mu.Lock()
s.shardErrors = n
s.mu.Unlock()
}
func (s *RunStats) shardHalted() {
if s == nil {
return
}
s.mu.Lock()
s.shardsHalted++
s.mu.Unlock()
}
func (s *RunStats) walkDeferred() {
if s == nil {
return
}
s.mu.Lock()
s.deferred++
s.mu.Unlock()
}
func (s *RunStats) Summary() RunSummary {
if s == nil {
return RunSummary{}
}
out := RunSummary{
EntriesVisited: s.entries.Load(),
ObjectsVisited: s.objects.Load(),
EventsScanned: s.events.Load(),
}
s.mu.Lock()
defer s.mu.Unlock()
out.Phase, out.Bucket = s.phase, s.bucket
for _, state := range s.buckets {
switch state {
case bucketWalked:
out.BucketsWalked++
case bucketFailed:
out.BucketsFailed++
case bucketSkipped:
out.BucketsSkipped++
}
}
out.BucketFailures = append([]BucketFailure(nil), s.failures...)
if len(s.outcomes) > 0 {
out.Outcomes = make(map[string]int64, len(s.outcomes))
for k, v := range s.outcomes {
out.Outcomes[k] = v
}
}
out.ShardErrors = s.shardErrors
out.ShardsHalted = s.shardsHalted
out.WalksDeferred = s.deferred
return out
}
func countDispatch(s *RunStats, bucket, kind, outcome string) {
stats.S3LifecycleDispatchCounter.WithLabelValues(bucket, kind, outcome).Inc()
s.recordOutcome(outcome)
}
func truncateError(err error) string {
if err == nil {
return ""
}
msg := err.Error()
if len(msg) <= maxReportedErrorLen {
return msg
}
return strings.ToValidUTF8(msg[:maxReportedErrorLen], "") + "..."
}