mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-08-21 14:46:58 +00:00
* feat(s3/lifecycle): throttle steady-state walker by cfg.WalkerInterval The steady-state and empty-replay walker fired on every dailyrun.Run invocation, which is fine when Run is called at the bucket-walk cadence the operator intends (e.g., once per hour or once per day), but catastrophic when a fast driver like the s3tests CI workflow or the admin worker scheduler invokes Run at multi-second cadence — each tick ran a full subtree scan per shard, crushing the filer. Decouple walker cadence from Run() invocation cadence: persist LastWalkedNs in the per-shard cursor and fire the steady-state / empty-replay walker only when (runNow - LastWalkedNs) >= cfg.WalkerInterval. Cold-start and recovery walker fires (RecoveryView) stay unconditional since those are bounded events that must run when their trigger condition (no cursor, hash mismatch) is met. Recovery walker fires also update LastWalkedNs so the subsequent steady-state pass doesn't double-walk. cfg.WalkerInterval=0 keeps the prior "fire every pass" behavior — the in-repo integration tests and s3tests fast driver continue to work unchanged. Production deployments should set this to the walk cost budget (typically 1h-24h depending on cluster size). Cursor file is back-compat: last_walked_ns is omitempty, so cursor files written before this change decode as LastWalkedNs=0, which walkerDue treats as "never walked steady-state" → walker fires next pass to establish the anchor (same path a cold-start cursor takes). No version bump. Operator surface for WalkerInterval is the dailyrun.Config struct; plumbing through worker.tasks.s3_lifecycle.Config and the admin schema is a follow-up. * fix(s3/lifecycle): suppress walker double-fire within a single pass Two gemini-code-assist findings: 1. walkerDue with interval=0 returned true even when lastWalkedNs == runNow.UnixNano() — the cold-start / recovery branch already fired the walker this pass, and the steady-state fall-through fired it again. RecoveryView is a superset of every per-shard partition, so the second walk added zero coverage and burned a full subtree scan. Add a within-pass guard at the front of walkerDue: if the cursor's LastWalkedNs equals runNow's UnixNano, the walker already ran this pass — skip. 2. The empty-replay branch passed persisted.LastWalkedNs to walkerDue instead of the local lastWalkedNs variable the rest of runShard threads through. Trivially equal at this point in the function, but the inconsistency would mask a future bug if any code above the branch ever sets lastWalkedNs. Test updates: TestWalkerDue gains the within-pass guard case plus a companion "earlier same pass still fires" sanity check. TestRunShard_ColdStartDoesNotDoubleWalk is new and pins the integration: cold-start runShard with WalkerInterval=0 must call cfg.Walker exactly once, not twice. * fix(s3/lifecycle): reject negative WalkerInterval + lift within-pass guard Two coderabbit findings: 1. validate() now rejects negative cfg.WalkerInterval. A typo like -1h previously fell through walkerDue's `interval <= 0` branch and silently re-enabled "walk every pass" — the exact behavior the throttle was added to prevent. The admin-config parser already clamps negative input to zero, but callers using dailyrun.Config directly (tests, embedders) now get a loud error instead. 2. Within-pass double-fire suppression moves out of walkerDue and into runShard's walkedThisPass local flag. walkerDue's equality check (lastWalkedNs == runNow.UnixNano) was correct in production (each pass freezes runNow at time.Now().UTC, no collisions) but fragile in tests that inject the same runNow across distinct passes — the test would see false suppression. Separating the concerns also makes walkerDue answer one question (persisted-state throttle) and runShard another (within-pass call-site dedup). walker_interval_test.go: TestValidate_RejectsNegativeWalkerInterval pins the new validation. TestWalkerDue's within-pass cases move out (the function is pure throttle now); TestRunShard_ColdStartDoesNot DoubleWalk still pins the integration behavior end-to-end.
164 lines
6.7 KiB
Go
164 lines
6.7 KiB
Go
// Package dailyrun implements the daily-replay s3 lifecycle worker
|
|
// described in weed/s3api/s3lifecycle/DESIGN.md. One pass per day per
|
|
// shard reads the meta-log forward from the persisted cursor, drains
|
|
// every event whose due_time is past now, and exits — replacing the
|
|
// streaming + heap pipeline with a bounded, idempotent scan.
|
|
//
|
|
// Phase 2 (this file's first version): replay-only. Buckets whose
|
|
// compiled rules include walker-bound action kinds (ExpirationDate,
|
|
// ExpiredDeleteMarker, NewerNoncurrent) or any scan_only promotion are
|
|
// refused with a typed error so flipping the algorithm flag is a loud
|
|
// failure rather than silent data loss. Phase 4 wires the walker and
|
|
// the recovery branch.
|
|
package dailyrun
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/dispatcher"
|
|
)
|
|
|
|
// CursorDir is the filer directory holding per-shard daily-replay
|
|
// cursor files. Kept distinct from dispatcher.CursorDir so a deployment
|
|
// running both algorithms during cutover doesn't have one trample the
|
|
// other's persisted state.
|
|
const CursorDir = "/etc/s3/lifecycle/daily-cursors"
|
|
|
|
// cursorFileVersion bumps when the on-disk shape changes. Phase 2
|
|
// writes version 1; Phase 4 will continue writing version 1 since the
|
|
// schema (TsNs + RuleSetHash + PromotedHash) is already final.
|
|
const cursorFileVersion = 1
|
|
|
|
// Cursor captures everything daily_run needs to decide whether to
|
|
// recover (Phase 4) or continue steady-state. TsNs is the latest
|
|
// meta-log event whose Matches all dispatched successfully (or as
|
|
// NOOP_RESOLVED); RuleSetHash is the content hash of the replay-eligible
|
|
// rules from the run that wrote this cursor; PromotedHash is the hash
|
|
// of replay-eligible rules currently in scan_only. LastWalkedNs is the
|
|
// runNow of the most recent successful steady-state / empty-replay
|
|
// walker fire on this shard — runShard uses it together with
|
|
// cfg.WalkerInterval to throttle the walker independently of Run()
|
|
// invocation frequency (so a worker driven by a 2s test ticker doesn't
|
|
// crush filer with a full bucket walk per tick).
|
|
//
|
|
// In Phase 2 PromotedHash is always the empty hash because the walker
|
|
// path isn't wired yet — any rule that would land in walk causes the
|
|
// run to refuse. Phase 4 starts writing a real value.
|
|
type Cursor struct {
|
|
TsNs int64
|
|
RuleSetHash [32]byte
|
|
PromotedHash [32]byte
|
|
LastWalkedNs int64
|
|
}
|
|
|
|
// cursorFile is the on-disk JSON shape. Bytes are base64-encoded by
|
|
// encoding/json automatically.
|
|
//
|
|
// last_walked_ns is omitempty so cursor files written before the field
|
|
// existed still decode cleanly: an older file has no last_walked_ns,
|
|
// which marshals back into LastWalkedNs=0, which runShard treats as
|
|
// "never walked steady-state" and so the next pass fires the walker
|
|
// (the same path a cold-start cursor takes). No version bump needed.
|
|
type cursorFile struct {
|
|
Version int `json:"version"`
|
|
ShardID int `json:"shard_id"`
|
|
TsNs int64 `json:"ts_ns"`
|
|
RuleSetHash []byte `json:"rule_set_hash"`
|
|
PromotedHash []byte `json:"promoted_hash"`
|
|
LastWalkedNs int64 `json:"last_walked_ns,omitempty"`
|
|
}
|
|
|
|
// CursorPersister loads and saves daily-replay cursors. The Phase 2
|
|
// production implementation is FilerCursorPersister; tests inject a
|
|
// fake.
|
|
type CursorPersister interface {
|
|
Load(ctx context.Context, shardID int) (Cursor, bool, error) // (cursor, found, err)
|
|
Save(ctx context.Context, shardID int, c Cursor) error
|
|
}
|
|
|
|
// FilerCursorPersister writes cursors to CursorDir as one JSON file per
|
|
// shard. Reuses dispatcher.FilerStore for the actual filer I/O so both
|
|
// algorithms share the same minimal storage abstraction.
|
|
type FilerCursorPersister struct {
|
|
Store dispatcher.FilerStore
|
|
}
|
|
|
|
func cursorFileName(shardID int) string {
|
|
return fmt.Sprintf("shard-%02d.json", shardID)
|
|
}
|
|
|
|
// Load returns (zero-Cursor, false, nil) only when the cursor file
|
|
// does not exist yet (cold start). Every other failure mode — empty
|
|
// file, malformed JSON, wrong version, wrong shard, hash slices not
|
|
// exactly 32 bytes — returns an error so the daily run halts and is
|
|
// fixed by an operator rather than silently re-scanning from time zero.
|
|
//
|
|
// Strict shape validation is load-bearing because the cursor is
|
|
// load → mutate → save in a single run: a partial-truncate that
|
|
// silently zero-padded the rule_set_hash would be persisted back as a
|
|
// real-looking hash, masking the corruption forever.
|
|
func (p *FilerCursorPersister) Load(ctx context.Context, shardID int) (Cursor, bool, error) {
|
|
if p.Store == nil {
|
|
return Cursor{}, false, errors.New("FilerCursorPersister: nil Store")
|
|
}
|
|
content, err := p.Store.Read(ctx, CursorDir, cursorFileName(shardID))
|
|
if err != nil {
|
|
if errors.Is(err, filer_pb.ErrNotFound) {
|
|
return Cursor{}, false, nil
|
|
}
|
|
return Cursor{}, false, fmt.Errorf("cursor read shard=%d: %w", shardID, err)
|
|
}
|
|
if len(content) == 0 {
|
|
return Cursor{}, false, fmt.Errorf("cursor shard=%d: file exists but is empty (partial write or external truncation)", shardID)
|
|
}
|
|
var cf cursorFile
|
|
if err := json.Unmarshal(content, &cf); err != nil {
|
|
return Cursor{}, false, fmt.Errorf("cursor decode shard=%d: %w", shardID, err)
|
|
}
|
|
if cf.Version != cursorFileVersion {
|
|
return Cursor{}, false, fmt.Errorf("cursor version shard=%d: got %d, want %d", shardID, cf.Version, cursorFileVersion)
|
|
}
|
|
if cf.ShardID != shardID {
|
|
return Cursor{}, false, fmt.Errorf("cursor shard mismatch: file declares shard=%d, requested shard=%d", cf.ShardID, shardID)
|
|
}
|
|
if len(cf.RuleSetHash) != 32 {
|
|
return Cursor{}, false, fmt.Errorf("cursor rule_set_hash shard=%d: got %d bytes, want 32", shardID, len(cf.RuleSetHash))
|
|
}
|
|
if len(cf.PromotedHash) != 32 {
|
|
return Cursor{}, false, fmt.Errorf("cursor promoted_hash shard=%d: got %d bytes, want 32", shardID, len(cf.PromotedHash))
|
|
}
|
|
c := Cursor{TsNs: cf.TsNs, LastWalkedNs: cf.LastWalkedNs}
|
|
copy(c.RuleSetHash[:], cf.RuleSetHash)
|
|
copy(c.PromotedHash[:], cf.PromotedHash)
|
|
return c, true, nil
|
|
}
|
|
|
|
func (p *FilerCursorPersister) Save(ctx context.Context, shardID int, c Cursor) error {
|
|
if p.Store == nil {
|
|
return errors.New("FilerCursorPersister: nil Store")
|
|
}
|
|
cf := cursorFile{
|
|
Version: cursorFileVersion,
|
|
ShardID: shardID,
|
|
TsNs: c.TsNs,
|
|
RuleSetHash: c.RuleSetHash[:],
|
|
PromotedHash: c.PromotedHash[:],
|
|
LastWalkedNs: c.LastWalkedNs,
|
|
}
|
|
var buf bytes.Buffer
|
|
enc := json.NewEncoder(&buf)
|
|
enc.SetIndent("", " ")
|
|
if err := enc.Encode(cf); err != nil {
|
|
return fmt.Errorf("cursor encode shard=%d: %w", shardID, err)
|
|
}
|
|
if err := p.Store.Save(ctx, CursorDir, cursorFileName(shardID), buf.Bytes()); err != nil {
|
|
return fmt.Errorf("cursor save shard=%d: %w", shardID, err)
|
|
}
|
|
return nil
|
|
}
|