mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-15 08:18:42 +01:00
* 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>
186 lines
6.2 KiB
Go
186 lines
6.2 KiB
Go
package dailyrun
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// fakeStore is a minimal in-memory dispatcher.FilerStore for cursor tests.
|
|
type fakeStore struct {
|
|
mu sync.Mutex
|
|
files map[string][]byte
|
|
}
|
|
|
|
func newFakeStore() *fakeStore {
|
|
return &fakeStore{files: map[string][]byte{}}
|
|
}
|
|
|
|
func (s *fakeStore) key(dir, name string) string { return dir + "/" + name }
|
|
|
|
func (s *fakeStore) Read(_ context.Context, dir, name string) ([]byte, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
b, ok := s.files[s.key(dir, name)]
|
|
if !ok {
|
|
return nil, filer_pb.ErrNotFound
|
|
}
|
|
return append([]byte(nil), b...), nil
|
|
}
|
|
|
|
func (s *fakeStore) Save(_ context.Context, dir, name string, content []byte) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.files[s.key(dir, name)] = append([]byte(nil), content...)
|
|
return nil
|
|
}
|
|
|
|
func TestFilerCursorPersister_EmptyLoadReturnsNotFound(t *testing.T) {
|
|
p := &FilerCursorPersister{Store: newFakeStore()}
|
|
c, found, err := p.Load(context.Background(), 0)
|
|
require.NoError(t, err)
|
|
assert.False(t, found)
|
|
assert.Equal(t, Cursor{}, c)
|
|
}
|
|
|
|
func TestFilerCursorPersister_SaveLoadRoundTrip(t *testing.T) {
|
|
p := &FilerCursorPersister{Store: newFakeStore()}
|
|
in := Cursor{
|
|
TsNs: 1700000000_000000123,
|
|
}
|
|
for i := range in.RuleSetHash {
|
|
in.RuleSetHash[i] = byte(i + 1)
|
|
}
|
|
for i := range in.PromotedHash {
|
|
in.PromotedHash[i] = byte(0xff - i)
|
|
}
|
|
require.NoError(t, p.Save(context.Background(), 3, in))
|
|
|
|
out, found, err := p.Load(context.Background(), 3)
|
|
require.NoError(t, err)
|
|
require.True(t, found)
|
|
assert.Equal(t, in, out)
|
|
}
|
|
|
|
func TestFilerCursorPersister_IsolatesShards(t *testing.T) {
|
|
p := &FilerCursorPersister{Store: newFakeStore()}
|
|
a := Cursor{TsNs: 100}
|
|
b := Cursor{TsNs: 200}
|
|
require.NoError(t, p.Save(context.Background(), 0, a))
|
|
require.NoError(t, p.Save(context.Background(), 1, b))
|
|
|
|
got0, _, err := p.Load(context.Background(), 0)
|
|
require.NoError(t, err)
|
|
got1, _, err := p.Load(context.Background(), 1)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int64(100), got0.TsNs)
|
|
assert.Equal(t, int64(200), got1.TsNs)
|
|
}
|
|
|
|
func TestFilerCursorPersister_CorruptDataReturnsError(t *testing.T) {
|
|
store := newFakeStore()
|
|
store.files[store.key(CursorDir, "shard-00.json")] = []byte("not json")
|
|
p := &FilerCursorPersister{Store: store}
|
|
_, _, err := p.Load(context.Background(), 0)
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestFilerCursorPersister_EmptyFileReturnsError(t *testing.T) {
|
|
// A cursor file that exists but is empty signals a partial write
|
|
// or external truncation. Treating it as "cold start" would silently
|
|
// re-scan from time zero and burn through a meta-log window for no
|
|
// reason. Pin that we error instead.
|
|
store := newFakeStore()
|
|
store.files[store.key(CursorDir, "shard-00.json")] = []byte{}
|
|
p := &FilerCursorPersister{Store: store}
|
|
_, _, err := p.Load(context.Background(), 0)
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestFilerCursorPersister_WrongVersionReturnsError(t *testing.T) {
|
|
// Future schema bumps must not silently overwrite a cursor written
|
|
// by a newer worker. Pin that contract here.
|
|
store := newFakeStore()
|
|
store.files[store.key(CursorDir, "shard-00.json")] = []byte(`{"version":42,"shard_id":0,"ts_ns":0,"rule_set_hash":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=","promoted_hash":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="}`)
|
|
p := &FilerCursorPersister{Store: store}
|
|
_, _, err := p.Load(context.Background(), 0)
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestFilerCursorPersister_ShardIDMismatchReturnsError(t *testing.T) {
|
|
// A file declaring shard=1 read by a shard=0 caller is corruption
|
|
// (or a misroute); applying that cursor would advance the wrong
|
|
// shard's state. Reject loudly.
|
|
store := newFakeStore()
|
|
// Build a valid v1 payload but with shard_id=1 in the body.
|
|
in := Cursor{TsNs: 42}
|
|
for i := range in.RuleSetHash {
|
|
in.RuleSetHash[i] = byte(i + 1)
|
|
}
|
|
for i := range in.PromotedHash {
|
|
in.PromotedHash[i] = byte(i)
|
|
}
|
|
p := &FilerCursorPersister{Store: store}
|
|
require.NoError(t, p.Save(context.Background(), 1, in))
|
|
// Reparent that file as if it lived at shard 0 — the test's fake
|
|
// store only keys by name, so we copy the bytes manually.
|
|
store.mu.Lock()
|
|
store.files[store.key(CursorDir, "shard-00.json")] = store.files[store.key(CursorDir, "shard-01.json")]
|
|
store.mu.Unlock()
|
|
_, _, err := p.Load(context.Background(), 0)
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestFilerCursorPersister_HashLengthMismatchReturnsError(t *testing.T) {
|
|
// Hand-rolled JSON with hashes shorter than 32 bytes. copy() would
|
|
// silently zero-pad which makes the hash comparison match a value
|
|
// that doesn't actually exist on disk. Reject loudly so an operator
|
|
// fixes the persisted state.
|
|
store := newFakeStore()
|
|
store.files[store.key(CursorDir, "shard-00.json")] = []byte(`{"version":1,"shard_id":0,"ts_ns":0,"rule_set_hash":"AA==","promoted_hash":"AA=="}`)
|
|
p := &FilerCursorPersister{Store: store}
|
|
_, _, err := p.Load(context.Background(), 0)
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestFilerCursorPersister_NilStoreErrors(t *testing.T) {
|
|
p := &FilerCursorPersister{}
|
|
_, _, err := p.Load(context.Background(), 0)
|
|
require.Error(t, err)
|
|
require.Error(t, p.Save(context.Background(), 0, Cursor{}))
|
|
}
|
|
|
|
func TestFilerCursorPersister_FullPassRoundTrip(t *testing.T) {
|
|
store := newFakeStore()
|
|
p := &FilerCursorPersister{Store: store}
|
|
_, found, err := p.LoadFullPass(context.Background())
|
|
require.NoError(t, err)
|
|
assert.False(t, found)
|
|
|
|
want := FullPass{
|
|
StartedAt: time.Unix(1_700_000_000, 123).UTC(),
|
|
CompletedAt: time.Unix(1_700_003_600, 456).UTC(),
|
|
}
|
|
require.NoError(t, p.SaveFullPass(context.Background(), want))
|
|
got, found, err := p.LoadFullPass(context.Background())
|
|
require.NoError(t, err)
|
|
require.True(t, found)
|
|
assert.Equal(t, want, got)
|
|
|
|
_, found, err = p.Load(context.Background(), 0)
|
|
require.NoError(t, err)
|
|
assert.False(t, found, "the full-pass record must not read as a shard cursor")
|
|
}
|
|
|
|
func TestFilerCursorPersister_FullPassRejectsUnknownVersion(t *testing.T) {
|
|
store := newFakeStore()
|
|
require.NoError(t, store.Save(context.Background(), CursorDir, fullPassFileName, []byte(`{"version":99,"completed_at_ns":1}`)))
|
|
_, _, err := (&FilerCursorPersister{Store: store}).LoadFullPass(context.Background())
|
|
assert.Error(t, err)
|
|
}
|