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>
This commit is contained in:
Chris LuandClaude Opus 5.5 authored and GitHub committed 2026-11-14 10:50:37 -08:00
1 parent 6ad4b07598
commit c819e3fbea
18 files changed
+1119 -52

No files matched your search

+72 -23
View File
@@ -19,6 +19,7 @@ import (
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/dispatcher"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/engine"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/scheduler"
"github.com/seaweedfs/seaweedfs/weed/stats"
"golang.org/x/time/rate"
"google.golang.org/grpc"
)
@@ -257,25 +258,27 @@ func (h *Handler) Execute(ctx context.Context, request *plugin_pb.ExecuteJobRequ
defer s3Conn.Close()
rpc := s3_lifecycle_pb.NewSeaweedS3LifecycleInternalClient(s3Conn)
if err := h.executeDailyReplay(runCtx, request, bucketsPath, filerClient, rpc, cfg, sender); err != nil {
report, err := h.executeDailyReplay(runCtx, request, bucketsPath, filerClient, rpc, cfg, sender)
if err != nil {
return err
}
return sendSuccessCompletion(request, sender)
return sendCompletion(request, sender, report)
}
const dailyReplaySuccessSummary = "s3 lifecycle daily replay completed"
// JobCompleted must set JobType to the handler's jobType constant;
// routed requests may leave request.Job.JobType empty, and the admin
// ignores completions with empty JobType.
func sendSuccessCompletion(request *plugin_pb.ExecuteJobRequest, sender pluginworker.ExecutionSender) error {
// ignores completions with empty JobType. Failed passes carry their
// result too, so every bucket failure stays in the job record.
func sendCompletion(request *plugin_pb.ExecuteJobRequest, sender pluginworker.ExecutionSender, report *runReport) error {
return sender.SendCompleted(&plugin_pb.JobCompleted{
JobId: request.Job.JobId,
JobType: jobType,
Success: true,
JobId: request.Job.JobId,
JobType: jobType,
Success: report.err == nil,
ErrorMessage: report.errorMessage(),
Result: &plugin_pb.JobResult{
Summary: dailyReplaySuccessSummary,
Summary: report.summaryLine(),
OutputValues: report.outputValues(),
},
})
}
@@ -283,14 +286,17 @@ func sendSuccessCompletion(request *plugin_pb.ExecuteJobRequest, sender pluginwo
// executeDailyReplay runs one bounded daily-replay pass via
// dailyrun.Run. The walker fires inside runShard on rule-content edits
// and against the steady-state walk view; all rule kinds are serviced.
func (h *Handler) executeDailyReplay(ctx context.Context, request *plugin_pb.ExecuteJobRequest, bucketsPath string, filerClient filer_pb.SeaweedFilerClient, rpc s3_lifecycle_pb.SeaweedS3LifecycleInternalClient, cfg Config, sender pluginworker.ExecutionSender) error {
// The returned error covers setup only; the pass's own error is in the
// report.
func (h *Handler) executeDailyReplay(ctx context.Context, request *plugin_pb.ExecuteJobRequest, bucketsPath string, filerClient filer_pb.SeaweedFilerClient, rpc s3_lifecycle_pb.SeaweedS3LifecycleInternalClient, cfg Config, sender pluginworker.ExecutionSender) (*runReport, error) {
startedAt := time.Now()
eng := engine.New()
inputs, parseErrors, err := scheduler.LoadCompileInputs(ctx, filerClient, bucketsPath)
if err != nil {
return fmt.Errorf("daily_replay: load lifecycle inputs: %w", err)
return nil, fmt.Errorf("daily_replay: load lifecycle inputs: %w", err)
}
for _, pe := range parseErrors {
glog.V(1).Infof("daily_replay: %s: %v", pe.Bucket, pe.Err)
glog.Warningf("daily_replay: invalid lifecycle policy on bucket %s: %v", pe.Bucket, pe.Err)
}
eng.Compile(inputs, engine.CompileOptions{PriorStates: scheduler.AllActivePriorStates(inputs)})
@@ -313,28 +319,33 @@ func (h *Handler) executeDailyReplay(ctx context.Context, request *plugin_pb.Exe
buckets = append(buckets, in.Bucket)
}
}
persister := &dailyrun.FilerCursorPersister{Store: dispatcher.NewFilerStoreClient(filerClient)}
report := &runReport{bucketsTotal: len(buckets) + len(parseErrors), invalidPolicies: parseErrors}
loadLastFullPass(ctx, persister, report)
runStats := &dailyrun.RunStats{}
// Share the limiter with processMatches so walker + replay can't
// combine to burst past the cluster cap.
walkerDispatch := &dailyrun.WalkerDispatcher{Client: client, Limiter: limiter}
walkerDispatch := &dailyrun.WalkerDispatcher{Client: client, Limiter: limiter, Stats: runStats}
walker := dailyrun.WalkerFunc(func(walkCtx context.Context, view *engine.Snapshot, shards []int) error {
return dailyrun.WalkBuckets(walkCtx, view, shards, buckets,
dailyrun.FilerListFunc(filerClient, bucketsPath, view), walkerDispatch)
dailyrun.FilerListFunc(filerClient, bucketsPath, view), walkerDispatch, runStats)
})
_ = sender.SendProgress(&plugin_pb.JobProgressUpdate{
JobId: request.Job.JobId, JobType: jobType,
State: plugin_pb.JobState_JOB_STATE_RUNNING, Stage: "starting",
Message: fmt.Sprintf("daily_replay shards=%d workers=%d rate=%s buckets=%d walker=on",
len(shards), cfg.Workers, limiterDesc, len(buckets)),
Message: fmt.Sprintf("daily_replay shards=%d workers=%d rate=%s buckets=%d invalid_policies=%d walker=on",
len(shards), cfg.Workers, limiterDesc, len(buckets), len(parseErrors)),
})
runErr := dailyrun.Run(ctx, dailyrun.Config{
report.err = dailyrun.Run(ctx, dailyrun.Config{
Shards: shards,
BucketsPath: bucketsPath,
Engine: eng,
FilerClient: filerClient,
Client: client,
Persister: &dailyrun.FilerCursorPersister{Store: dispatcher.NewFilerStoreClient(filerClient)},
Persister: persister,
Lister: dispatcher.NewFilerSiblingLister(filerClient, bucketsPath),
Workers: cfg.Workers,
Limiter: limiter,
@@ -342,12 +353,50 @@ func (h *Handler) executeDailyReplay(ctx context.Context, request *plugin_pb.Exe
Walker: walker,
WalkerInterval: cfg.WalkerInterval,
ClientName: "worker-s3-lifecycle-daily",
Stats: runStats,
})
if runErr != nil {
glog.Warningf("daily_replay: %v", runErr)
return runErr
report.canceled = ctx.Err() != nil
report.elapsed = time.Since(startedAt)
report.summary = runStats.Summary()
if report.err != nil {
glog.Warningf("daily_replay: %v", report.err)
}
return nil
recordFullPass(ctx, persister, report, startedAt)
return report, nil
}
func loadLastFullPass(ctx context.Context, persister *dailyrun.FilerCursorPersister, report *runReport) {
fp, found, err := persister.LoadFullPass(ctx)
if err != nil {
glog.Warningf("daily_replay: %v", err)
return
}
if found {
report.lastFullPass = fp.CompletedAt
publishFullPass(fp)
}
}
// recordFullPass advances the full-pass record only when the pass
// covered every shard and every policy.
func recordFullPass(ctx context.Context, persister *dailyrun.FilerCursorPersister, report *runReport, startedAt time.Time) {
if !report.fullPass() {
return
}
fp := dailyrun.FullPass{StartedAt: startedAt.UTC(), CompletedAt: time.Now().UTC()}
saveCtx, saveCancel := context.WithTimeout(ctx, 10*time.Second)
defer saveCancel()
if err := persister.SaveFullPass(saveCtx, fp); err != nil {
glog.Warningf("daily_replay: %v", err)
report.fullPassSaveErr = err
return
}
report.lastFullPass = fp.CompletedAt
publishFullPass(fp)
}
func publishFullPass(fp dailyrun.FullPass) {
stats.S3LifecycleDailyRunLastFullPassTimestampSeconds.Set(float64(fp.CompletedAt.UnixNano()) / 1e9)
}
// buildLimiterFromClusterContext parses the per-worker share the admin
+148 -6
View File
@@ -4,10 +4,16 @@ import (
"context"
"errors"
"math"
"strings"
"testing"
"time"
"github.com/prometheus/client_golang/prometheus/testutil"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/dailyrun"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/scheduler"
"github.com/seaweedfs/seaweedfs/weed/stats"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
@@ -468,15 +474,15 @@ func TestExecute_EmptyJobTypeAccepted(t *testing.T) {
assert.Contains(t, err.Error(), "no s3 servers", "validation flowed past the type check")
}
// ---------- sendSuccessCompletion ----------
// ---------- sendCompletion ----------
func TestSendSuccessCompletion_EmitsCanonicalCompletion(t *testing.T) {
func TestSendCompletion_EmitsCanonicalCompletion(t *testing.T) {
sender := &recordingExecSender{}
request := &plugin_pb.ExecuteJobRequest{
Job: &plugin_pb.JobSpec{JobId: "job-123"}, // empty JobType (broadcast routing)
}
require.NoError(t, sendSuccessCompletion(request, sender))
require.NoError(t, sendCompletion(request, sender, &runReport{bucketsTotal: 2}))
require.Len(t, sender.completed, 1, "exactly one completion must be sent")
c := sender.completed[0]
@@ -484,20 +490,140 @@ func TestSendSuccessCompletion_EmitsCanonicalCompletion(t *testing.T) {
assert.NotEmpty(t, c.JobType, "an empty JobType is dropped by the admin")
assert.Equal(t, "job-123", c.JobId, "completion must echo the original JobId")
assert.True(t, c.Success, "a clean replay is a success completion")
assert.Empty(t, c.ErrorMessage)
require.NotNil(t, c.Result)
assert.NotEmpty(t, c.Result.Summary, "admin UI surfaces a non-empty Result.Summary")
assert.True(t, strings.HasPrefix(c.Result.Summary, "s3 lifecycle daily replay completed:"), c.Result.Summary)
assert.Equal(t, runStatusComplete, c.Result.OutputValues["status"].GetStringValue())
assert.Equal(t, int64(2), c.Result.OutputValues["buckets_total"].GetInt64Value())
}
func TestSendSuccessCompletion_PropagatesSendError(t *testing.T) {
func TestSendCompletion_PropagatesSendError(t *testing.T) {
sentinel := errors.New("stream closed")
sender := &recordingExecSender{completedErr: sentinel}
request := &plugin_pb.ExecuteJobRequest{Job: &plugin_pb.JobSpec{JobId: "job-456"}}
err := sendSuccessCompletion(request, sender)
err := sendCompletion(request, sender, &runReport{})
require.Error(t, err)
assert.ErrorIs(t, err, sentinel)
}
func TestSendCompletion_FailedPassKeepsEveryBucketFailure(t *testing.T) {
sender := &recordingExecSender{}
report := &runReport{
err: errors.New("shard=3: steady walk: walk bad-a: filer down"),
bucketsTotal: 3,
summary: dailyrun.RunSummary{
BucketsWalked: 1,
BucketsFailed: 2,
BucketFailures: []dailyrun.BucketFailure{
{Bucket: "bad-a", Error: "filer down"},
{Bucket: "bad-b", Error: "outcome=BLOCKED"},
},
Outcomes: map[string]int64{"DONE": 4, "RPC_ERROR": 1},
},
}
require.NoError(t, sendCompletion(&plugin_pb.ExecuteJobRequest{Job: &plugin_pb.JobSpec{JobId: "j"}}, sender, report))
require.Len(t, sender.completed, 1)
c := sender.completed[0]
assert.False(t, c.Success)
assert.Contains(t, c.ErrorMessage, "walk bad-a: filer down")
assert.Contains(t, c.ErrorMessage, "2 of 3 buckets failed")
require.NotNil(t, c.Result, "a failed pass still reports its result")
values := c.Result.OutputValues
assert.Equal(t, runStatusFailed, values["status"].GetStringValue())
assert.Equal(t, int64(2), values["buckets_failed"].GetInt64Value())
assert.Equal(t, []string{"bad-a: filer down", "bad-b: outcome=BLOCKED"}, values["bucket_failures"].GetStringList().GetValues())
assert.Equal(t, int64(4), values["rpc_done"].GetInt64Value())
assert.Equal(t, int64(1), values["rpc_error"].GetInt64Value())
assert.True(t, strings.HasPrefix(c.Result.Summary, "s3 lifecycle daily replay failed:"), c.Result.Summary)
}
func TestRunReport_InvalidPolicyIsPartial(t *testing.T) {
report := &runReport{
bucketsTotal: 2,
invalidPolicies: []scheduler.ParseError{{Bucket: "broken", Err: errors.New("bad xml")}},
}
assert.Equal(t, runStatusPartial, report.status())
assert.True(t, strings.HasPrefix(report.summaryLine(), "s3 lifecycle daily replay partial:"), report.summaryLine())
assert.Contains(t, report.summaryLine(), "invalid_policies=1")
values := report.outputValues()
assert.Equal(t, int64(1), values["invalid_policies"].GetInt64Value())
assert.Equal(t, []string{"broken: bad xml"}, values["invalid_policy_buckets"].GetStringList().GetValues())
}
func TestRunReport_CanceledWinsOverError(t *testing.T) {
report := &runReport{canceled: true, err: context.Canceled}
assert.Equal(t, runStatusCanceled, report.status())
report.err = nil
assert.Equal(t, runStatusCanceled, report.status(), "a drain cut short returns no error")
}
// ---------- full pass record ----------
type memFilerStore struct {
files map[string][]byte
saveErr error
}
func (s *memFilerStore) Read(_ context.Context, dir, name string) ([]byte, error) {
b, ok := s.files[dir+"/"+name]
if !ok {
return nil, filer_pb.ErrNotFound
}
return b, nil
}
func (s *memFilerStore) Save(_ context.Context, dir, name string, content []byte) error {
if s.saveErr != nil {
return s.saveErr
}
s.files[dir+"/"+name] = content
return nil
}
func TestRecordFullPass_OnlyACompletePassAdvances(t *testing.T) {
previous := dailyrun.FullPass{StartedAt: time.Unix(1_700_000_000, 0).UTC(), CompletedAt: time.Unix(1_700_000_600, 0).UTC()}
for name, report := range map[string]*runReport{
"failed": {err: errors.New("walk failed")},
"canceled": {canceled: true},
"partial": {invalidPolicies: []scheduler.ParseError{{Bucket: "b", Err: errors.New("bad")}}},
"halted": {summary: dailyrun.RunSummary{ShardsHalted: 1}},
"deferred": {summary: dailyrun.RunSummary{WalksDeferred: 16}},
} {
t.Run(name, func(t *testing.T) {
persister := &dailyrun.FilerCursorPersister{Store: &memFilerStore{files: map[string][]byte{}}}
require.NoError(t, persister.SaveFullPass(context.Background(), previous))
loadLastFullPass(context.Background(), persister, report)
recordFullPass(context.Background(), persister, report, time.Now())
got, found, err := persister.LoadFullPass(context.Background())
require.NoError(t, err)
require.True(t, found)
assert.Equal(t, previous, got)
assert.Equal(t, previous.CompletedAt, report.lastFullPass)
assert.Equal(t, "2023-11-14T22:23:20Z", report.outputValues()["last_full_pass_at"].GetStringValue())
})
}
t.Run("complete", func(t *testing.T) {
persister := &dailyrun.FilerCursorPersister{Store: &memFilerStore{files: map[string][]byte{}}}
require.NoError(t, persister.SaveFullPass(context.Background(), previous))
report := &runReport{}
startedAt := time.Now().Add(-time.Minute)
recordFullPass(context.Background(), persister, report, startedAt)
got, found, err := persister.LoadFullPass(context.Background())
require.NoError(t, err)
require.True(t, found)
assert.True(t, got.CompletedAt.After(previous.CompletedAt))
assert.Equal(t, startedAt.UTC(), got.StartedAt)
assert.Equal(t, got.CompletedAt, report.lastFullPass)
assert.InDelta(t, float64(got.CompletedAt.UnixNano())/1e9,
testutil.ToFloat64(stats.S3LifecycleDailyRunLastFullPassTimestampSeconds), 1e-3)
})
}
// ---------- lookupBucketsPath ----------
// stubFilerConfigClient implements filer_pb.SeaweedFilerClient for the
@@ -548,3 +674,19 @@ func TestLookupBucketsPath_EmptyDirBucketsFallsBackToDefault(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "/buckets", got)
}
func TestRecordFullPass_FailedSaveKeepsThePreviousTimestamp(t *testing.T) {
store := &memFilerStore{files: map[string][]byte{}}
persister := &dailyrun.FilerCursorPersister{Store: store}
previous := dailyrun.FullPass{StartedAt: time.Unix(1_700_000_000, 0).UTC(), CompletedAt: time.Unix(1_700_000_600, 0).UTC()}
require.NoError(t, persister.SaveFullPass(context.Background(), previous))
report := &runReport{}
loadLastFullPass(context.Background(), persister, report)
store.saveErr = errors.New("filer unavailable")
recordFullPass(context.Background(), persister, report, time.Now())
assert.Equal(t, previous.CompletedAt, report.lastFullPass, "an unsaved pass must not replace the saved timestamp")
assert.InDelta(t, float64(previous.CompletedAt.Unix()), testutil.ToFloat64(stats.S3LifecycleDailyRunLastFullPassTimestampSeconds), 1e-3)
assert.Contains(t, report.outputValues()["full_pass_save_error"].GetStringValue(), "filer unavailable")
}
@@ -0,0 +1,142 @@
package s3_lifecycle
import (
"fmt"
"sort"
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/dailyrun"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/scheduler"
"google.golang.org/protobuf/types/known/durationpb"
)
const (
runStatusComplete = "complete"
runStatusPartial = "partial"
runStatusFailed = "failed"
runStatusCanceled = "canceled"
)
const maxReportedInvalidPolicies = 10
// runReport is one pass's outcome as the job record shows it.
type runReport struct {
err error
canceled bool
elapsed time.Duration
bucketsTotal int
invalidPolicies []scheduler.ParseError
summary dailyrun.RunSummary
lastFullPass time.Time
fullPassSaveErr error
}
// Bucket walk and shard failures always surface as err. A pass without
// one is still partial when a policy could not be loaded or a shard's
// replay halted on an unresolved delete.
func (r *runReport) status() string {
switch {
case r.canceled:
return runStatusCanceled
case r.err != nil:
return runStatusFailed
case len(r.invalidPolicies) > 0, r.summary.ShardsHalted > 0:
return runStatusPartial
}
return runStatusComplete
}
// fullPass also needs every walk-only rule evaluated: a walk deferred by
// WalkerInterval is by design, but it leaves those rules unchecked.
func (r *runReport) fullPass() bool {
return r.status() == runStatusComplete && r.summary.WalksDeferred == 0
}
func (r *runReport) summaryLine() string {
verb := r.status()
if verb == runStatusComplete {
verb = "completed"
}
s := r.summary
return fmt.Sprintf("s3 lifecycle daily replay %s: buckets=%d walked=%d failed=%d skipped=%d invalid_policies=%d entries=%d events=%d rpc_done=%d rpc_errors=%d elapsed=%s",
verb, r.bucketsTotal, s.BucketsWalked, s.BucketsFailed, s.BucketsSkipped, len(r.invalidPolicies),
s.EntriesVisited, s.EventsScanned, s.Outcomes["DONE"], s.Outcomes["RPC_ERROR"], r.elapsed.Round(time.Second))
}
func (r *runReport) errorMessage() string {
if r.err == nil {
return ""
}
if n := r.summary.BucketsFailed; n > 1 {
return fmt.Sprintf("%v (%d of %d buckets failed)", r.err, n, r.bucketsTotal)
}
return r.err.Error()
}
func (r *runReport) outputValues() map[string]*plugin_pb.ConfigValue {
s := r.summary
out := map[string]*plugin_pb.ConfigValue{
"status": stringValue(r.status()),
"buckets_total": int64Value(int64(r.bucketsTotal)),
"buckets_walked": int64Value(int64(s.BucketsWalked)),
"buckets_failed": int64Value(int64(s.BucketsFailed)),
"buckets_skipped": int64Value(int64(s.BucketsSkipped)),
"invalid_policies": int64Value(int64(len(r.invalidPolicies))),
"entries_visited": int64Value(s.EntriesVisited),
"objects_visited": int64Value(s.ObjectsVisited),
"events_scanned": int64Value(s.EventsScanned),
"shard_errors": int64Value(int64(s.ShardErrors)),
"shards_halted": int64Value(int64(s.ShardsHalted)),
"walks_deferred": int64Value(int64(s.WalksDeferred)),
"full_pass": {Kind: &plugin_pb.ConfigValue_BoolValue{BoolValue: r.fullPass()}},
"elapsed": {Kind: &plugin_pb.ConfigValue_DurationValue{DurationValue: durationpb.New(r.elapsed.Round(time.Millisecond))}},
}
for outcome, n := range s.Outcomes {
out["rpc_"+strings.ToLower(strings.TrimPrefix(outcome, "RPC_"))] = int64Value(n)
}
if len(s.BucketFailures) > 0 {
failures := make([]string, 0, len(s.BucketFailures))
for _, f := range s.BucketFailures {
failures = append(failures, f.Bucket+": "+f.Error)
}
out["bucket_failures"] = stringListValue(failures)
}
if len(r.invalidPolicies) > 0 {
invalid := make([]string, 0, maxReportedInvalidPolicies)
for _, pe := range r.invalidPolicies {
if len(invalid) == maxReportedInvalidPolicies {
break
}
invalid = append(invalid, fmt.Sprintf("%s: %v", pe.Bucket, pe.Err))
}
sort.Strings(invalid)
out["invalid_policy_buckets"] = stringListValue(invalid)
}
if s.Phase != "" {
out["last_phase"] = stringValue(s.Phase)
}
if s.Bucket != "" {
out["last_bucket"] = stringValue(s.Bucket)
}
if r.fullPassSaveErr != nil {
out["full_pass_save_error"] = stringValue(r.fullPassSaveErr.Error())
}
if !r.lastFullPass.IsZero() {
out["last_full_pass_at"] = stringValue(r.lastFullPass.UTC().Format(time.RFC3339))
}
return out
}
func stringValue(v string) *plugin_pb.ConfigValue {
return &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_StringValue{StringValue: v}}
}
func int64Value(v int64) *plugin_pb.ConfigValue {
return &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: v}}
}
func stringListValue(v []string) *plugin_pb.ConfigValue {
return &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_StringList{StringList: &plugin_pb.StringList{Values: v}}}
}