mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-20 13:30:46 +02:00
* docs(s3lifecycle): design for daily-replay worker Captures the algorithm and dev plan iterated on in PR #9431 and the discussion leading up to it: per-shard daily meta-log replay, walker as a per-day pass for ExpirationDate/ExpiredDeleteMarker/NewerNoncurrent plus a recovery branch over engine.RecoveryView(snap), explicit retention-window input to RulesForShard, two cursor hashes (ReplayContentHash + PromotedHash) that together detect every invalidation case. Implementation phases are sequenced so each can ship independently — Phase 1 (noncurrent_since stamp) just landed. * feat(s3/lifecycle): daily-replay worker behind algorithm flag (Phase 2) New weed/s3api/s3lifecycle/dailyrun package implementing the bounded daily meta-log scan from the design doc. One pass per Execute per shard: load cursor, scan events forward, route each through router.Route, dispatch any due Match, advance the cursor on success. Halt-on-failure keeps the cursor at the last fully-processed event so tomorrow resumes from the same point — head-of-line blocking is the deliberate failure signal. Replay-only in this phase. Phase 4 wires the walker for ExpirationDate, ExpiredDeleteMarker, NewerNoncurrent, and scan_only-promoted rules. Until then a typed UnsupportedRuleError refuses runs on those buckets: operators see the rejection in the activity log rather than silently losing rules. Behavior: - Per-shard cursor {TsNs, RuleSetHash, PromotedHash} JSON-persisted under /etc/s3/lifecycle/daily-cursors/. PromotedHash always-empty in Phase 2; Phase 4 turns it on. - Rule-change branch rewinds cursor to now - max_ttl when the replay-content hash mismatches. Cold start uses the same floor. - Transport errors retry 3x with exponential backoff capped at 5s; server outcomes (RETRY_LATER / BLOCKED) halt the run without retry. - Empty-replay sentinel: cursor TsNs=0 when no replay-eligible rules exist, only the hash gates a future addition. Worker shape: - New admin config field "algorithm" with enum streaming|daily_replay, default streaming. Existing deployments are unaffected. - handler.Execute branches on the flag: streaming routes through the current scheduler.Scheduler, daily_replay routes through dailyrun.Run. - dispatcher.NewFilerSiblingLister exported so both paths share the same .versions/ + null-bare lookup. Engine integration: - Local replayContentHash + maxEffectiveTTL helpers in dailyrun. Phase 4's engine surface (ReplayContentHash, MaxEffectiveTTL) will replace them with one-line redirects; the local versions hash the same fields so the cursor stays valid across the swap. Tests cover cursor persistence, unsupported-rule rejection, hash stability under rule reordering, hash sensitivity to TTL edits, max-TTL aggregation, dispatch retry budget, and request shape including the identity-CAS witness. Includes the design doc at weed/s3api/s3lifecycle/DESIGN.md so reviewers and future phases share the same spec. * feat(s3/lifecycle): default to daily_replay; streaming becomes the fallback knob The streaming dispatcher hasn't shipped to users yet, so there's no backward-compat surface to preserve. Flip the algorithm default from streaming to daily_replay so the new path is the standard from day one. Streaming stays as an explicit opt-in escape hatch during the Phase 4 walker rollout; Phase 5 deletes both the flag and the streaming code. Buckets whose lifecycle rules require walker-bound dispatch (ExpirationDate, ExpiredDeleteMarker, NewerNoncurrent, scan_only) will fail the daily_replay run with the existing UnsupportedRuleError until Phase 4 walker integration ships. Operators hitting that case can set algorithm=streaming until the follow-up lands. Updates the test for the default value and renames the unknown-value-fallback case to reflect the new default. * fix(s3/lifecycle/dailyrun): drop per-rule done flag — it suppressed due matches The done map was keyed by ActionKey = {Bucket, RuleHash, ActionKind}. That's only safe when each event produces at most one match per ActionKey with a single deterministic due-time formula — ExpirationDays and AbortMPU fit that shape because due_time = ev.TsNs + r.days is monotonic in event TsNs. But NoncurrentDays paired with NewerNoncurrentVersions > 0 (allowed in Phase 2 since it compiles to ActionKindNoncurrentDays) routes through routePointerTransitionExpand, which emits matches for every noncurrent sibling — each with its own SuccessorModTime taken from the demoting event for that specific sibling. A single event can therefore produce two matches for the same ActionKey on different objects with wildly different DueTimes. With the old code, a not-yet-due sibling encountered first would set done[ActionKey] = true and then the next sibling — even though its DueTime had already passed — would be skipped. Future events for the same rule would also be suppressed for the rest of the run. Objects that should have been deleted weren't. Fix: drop the early-stop optimization. Process every match independently. A future-DueTime match is now silently skipped without affecting any later match. The performance hit is small (Phase 2 is a single bounded daily pass, and the rate limiter is the real throughput governor); the correctness gain is non-negotiable. Also fixes the inverted comment in processMatches that described the old check as "due_time is past now" when it actually checked DueTime.After(now) (i.e., NOT yet due). Adds four targeted tests: - not-yet-due match first in slice does not suppress two later due matches for the same rule; - reversed slice ordering produces identical dispatch; - BLOCKED outcome halts the loop before later due matches are sent; - empty match slice is a no-op. Phase 4's walker-and-recovery integration can revisit a per-(rule, object) memoization if profiling argues for it. * fix(s3/lifecycle/dailyrun): address PR review — cursor advance, mode gate, ctx cancel, snapshot consistency Addresses PR #9446 review feedback. Eight distinct fixes: 1. CURSOR ADVANCEMENT (gemini, critical). The old code advanced the persisted cursor to lastOK = TsNs of the last event processed, including events whose matches were skipped as not-yet-due. Those skipped matches would never be re-scanned, so objects under long-TTL rules would never expire. Track a "stuck" flag in drainShardEvents: the first event with a skipped (future-DueTime) match stops cursorAdvanceTo from rising, but the loop keeps processing later events to dispatch any that ARE due. The persisted cursor sits at the last fully-processed event so tomorrow's run re-scans from the skipped event onward and the future-due matches get re-evaluated when they age in. processMatches now returns (skippedAny, halted, err) so the drain loop can tell apart "event fully drained" from "event had pending future-due matches." 2. MODE GATE (gemini). checkSnapshotForUnsupported only checked the ActionKind. A replay-eligible kind with Mode != ModeEventDriven (e.g. ModeScanOnly via retention promotion) passed the check but then got silently ignored by router.Route, which gates dispatch on Mode == ModeEventDriven. Reject loudly with the typed error so admin sees the rejection in the activity log. 3. WORKERS CONFIG (gemini). The handler hardcoded 16 concurrent shard goroutines regardless of cfg.Workers. Add a Workers field to dailyrun.Config and gate the goroutine fan-out on a semaphore of that size; the handler now passes cfg.Workers through. 4. SINGLE SNAPSHOT PER RUN (coderabbit). Run() validated against one snapshot but runShard() pulled a fresh cfg.Engine.Snapshot() per shard. Mid-run Compile would let shards process different rule sets. Capture snap at the top of Run, pass it down to every shard. 5. FROZEN runNow (coderabbit). drainShardEvents and processMatches accepted a `now func() time.Time` and called it multiple times. DueTime comparisons would slip as the run wore on. Capture runNow once at the top of Run and thread it through as a time.Time value. 6. CTX CANCELLATION (coderabbit). The drain loop's <-ctx.Done() case broke out of the loop and returned nil, marking interrupted runs as successful. Return ctx.Err() instead so the caller propagates the interrupt; cursorAdvanceTo carries whatever progress was made. 7. CURSOR LOAD VALIDATION (coderabbit + gemini). The persister silently accepted empty files, mismatched shard_ids, and hash slices shorter than 32 bytes (copy() would zero-pad). Each now returns a typed error so the run halts and an operator investigates rather than silently re-scanning from time zero or persisting a zero-padded hash that masks corruption forever. 8. DEAD BRANCH (coderabbit). The "lastOK < startTsNs → keep persisted" guard in runShard was unreachable because drainShardEvents initialized lastOK := startTsNs and only ever raised it. Removed along with the new cursor-advancement semantics that handle the "no events processed" case implicitly. Plus markdown lint: DESIGN.md fenced code blocks now carry a `text` language identifier to satisfy MD040. Skipped from the review: - gemini's "maxTTL == 0 incorrectly skips immediate expirations": actions with Days <= 0 don't compile to a CompiledAction (see weed/s3api/s3lifecycle/action_kind.go: `if rule.X > 0`). The new empty-replay sentinel uses `rsh == [32]byte{}` for clarity per gemini's suggested form, but the behavior is equivalent. Tests added/updated: - TestProcessMatches_AllDueNoSkippedFlag pins skippedAny=false when all matches are past their DueTime. - TestCheckSnapshotForUnsupported_NonEventDrivenModeRejected pins the new Mode check. - TestFilerCursorPersister_EmptyFileReturnsError, _ShardIDMismatchReturnsError, _HashLengthMismatchReturnsError pin the new validation rules. - Existing process-matches tests reshaped for the (skippedAny, halted, err) return tuple. Full build clean. Dailyrun + worker test packages green.
391 lines
16 KiB
Go
391 lines
16 KiB
Go
package s3_lifecycle
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/s3_lifecycle_pb"
|
|
pluginworker "github.com/seaweedfs/seaweedfs/weed/plugin/worker"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/dailyrun"
|
|
"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/util"
|
|
"google.golang.org/grpc"
|
|
)
|
|
|
|
func init() {
|
|
pluginworker.RegisterHandler(pluginworker.HandlerFactory{
|
|
JobType: jobType,
|
|
Category: pluginworker.CategoryDefault,
|
|
Aliases: []string{"s3-lifecycle", "s3.lifecycle", "lifecycle"},
|
|
Build: func(opts pluginworker.HandlerBuildOptions) (pluginworker.JobHandler, error) {
|
|
return NewHandler(opts.GrpcDialOption), nil
|
|
},
|
|
})
|
|
}
|
|
|
|
// Handler is the worker-side runner for S3 object lifecycle expiration.
|
|
// One Execute call drives a long-running scheduler.Scheduler against the
|
|
// S3 endpoints discovered from the master; admin caps concurrency at one
|
|
// job per worker so a fresh proposal only spawns a new run after the
|
|
// prior one exits.
|
|
type Handler struct {
|
|
grpcDialOption grpc.DialOption
|
|
}
|
|
|
|
func NewHandler(grpcDialOption grpc.DialOption) *Handler {
|
|
return &Handler{grpcDialOption: grpcDialOption}
|
|
}
|
|
|
|
func (h *Handler) Capability() *plugin_pb.JobTypeCapability {
|
|
return &plugin_pb.JobTypeCapability{
|
|
JobType: jobType,
|
|
CanDetect: true,
|
|
CanExecute: true,
|
|
MaxDetectionConcurrency: 1,
|
|
MaxExecutionConcurrency: 1,
|
|
DisplayName: "S3 Lifecycle",
|
|
Description: "Daily batch: scan the filer meta-log and delete objects whose lifecycle rule has fired.",
|
|
Weight: 20,
|
|
}
|
|
}
|
|
|
|
func (h *Handler) Descriptor() *plugin_pb.JobTypeDescriptor {
|
|
return &plugin_pb.JobTypeDescriptor{
|
|
JobType: jobType,
|
|
DisplayName: "S3 Lifecycle",
|
|
Description: "Daily S3 object expiration scan with per-shard cursors and bounded retry.",
|
|
Icon: "fas fa-recycle",
|
|
DescriptorVersion: 1,
|
|
AdminConfigForm: &plugin_pb.ConfigForm{
|
|
FormId: "s3-lifecycle-admin",
|
|
Title: "S3 Lifecycle",
|
|
Description: "Cluster-wide controls for the lifecycle scheduler.",
|
|
Sections: []*plugin_pb.ConfigSection{
|
|
{
|
|
SectionId: "scope",
|
|
Title: "Scope",
|
|
Description: "How many pipeline goroutines split the 16-shard space.",
|
|
Fields: []*plugin_pb.ConfigField{
|
|
{
|
|
Name: "workers",
|
|
Label: "Worker Count",
|
|
Description: "Number of pipeline goroutines per executing worker. Each owns a contiguous slice of [0, 16) shards. Default 1 = one goroutine handles all 16 shards.",
|
|
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
|
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
|
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
|
|
MaxValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 16}},
|
|
},
|
|
{
|
|
Name: "algorithm",
|
|
Label: "Algorithm",
|
|
Description: "Daily Replay = bounded daily meta-log scan (Phase 2, replay-only — buckets using ExpirationDate, ExpiredDeleteMarker, NewerNoncurrent, or scan_only rules will fail the run until Phase 4 ships). Streaming = legacy reader+heap path, kept as a runtime escape hatch during the Phase 4 rollout; Phase 5 deletes it.",
|
|
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_ENUM,
|
|
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_SELECT,
|
|
Options: []*plugin_pb.ConfigOption{
|
|
{Value: AlgorithmDailyReplay, Label: "Daily Replay (default)", Description: "Bounded daily meta-log scan. Replay-only in Phase 2; buckets with walker-bound rules fail the run."},
|
|
{Value: AlgorithmStreaming, Label: "Streaming (legacy fallback)", Description: "Long-running reader + per-shard heap + tick dispatcher. Pre-cutover behavior; removed in Phase 5."},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
DefaultValues: map[string]*plugin_pb.ConfigValue{
|
|
"workers": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultWorkers}},
|
|
"algorithm": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: defaultAlgorithm}},
|
|
},
|
|
},
|
|
WorkerConfigForm: &plugin_pb.ConfigForm{
|
|
FormId: "s3-lifecycle-worker",
|
|
Title: "S3 Lifecycle Worker",
|
|
Description: "Operational tuning for the lifecycle pipeline.",
|
|
Sections: []*plugin_pb.ConfigSection{
|
|
{
|
|
SectionId: "cadence",
|
|
Title: "Cadence",
|
|
Description: "How often the worker checks its schedule, saves progress, reloads bucket lifecycle configs, and how long each run may take.",
|
|
Fields: []*plugin_pb.ConfigField{
|
|
{
|
|
Name: "dispatch_tick_minutes",
|
|
Label: "Schedule Check Interval (minutes)",
|
|
Description: "How often each pipeline drains its schedule.",
|
|
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
|
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
|
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
|
|
},
|
|
{
|
|
Name: "checkpoint_tick_seconds",
|
|
Label: "Progress Save Interval (seconds)",
|
|
Description: "How often each pipeline persists its cursor map to the filer.",
|
|
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
|
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
|
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
|
|
},
|
|
{
|
|
Name: "refresh_interval_minutes",
|
|
Label: "Lifecycle Config Reload (minutes)",
|
|
Description: "How often the scheduler rebuilds the engine snapshot from bucket lifecycle configs.",
|
|
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
|
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
|
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
|
|
},
|
|
{
|
|
Name: "bootstrap_interval_minutes",
|
|
Label: "Full Bucket Rescan Interval (minutes)",
|
|
Description: "How often each bucket is re-walked. scan_only rules — those whose retention horizon exceeds meta-log retention — only fire from the bootstrap walk, so a non-zero value is required to enforce them on a long-running worker. 0 keeps the legacy walk-once-per-process behavior.",
|
|
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
|
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
|
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0}},
|
|
},
|
|
{
|
|
Name: "max_runtime_minutes",
|
|
Label: "Per-Run Time Limit (minutes)",
|
|
Description: "Wall-clock cap on each run. Each daily run processes events for one day; the cursor persists across runs.",
|
|
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
|
|
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
|
|
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 1}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
DefaultValues: map[string]*plugin_pb.ConfigValue{
|
|
"dispatch_tick_minutes": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultDispatchTickMinutes}},
|
|
"checkpoint_tick_seconds": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultCheckpointTickSeconds}},
|
|
"refresh_interval_minutes": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultRefreshIntervalMinutes}},
|
|
"bootstrap_interval_minutes": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultBootstrapIntervalMinutes}},
|
|
"max_runtime_minutes": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: defaultMaxRuntimeMinutes}},
|
|
},
|
|
},
|
|
AdminRuntimeDefaults: &plugin_pb.AdminRuntimeDefaults{
|
|
DetectionIntervalMinutes: 24 * 60, // daily
|
|
DetectionTimeoutSeconds: 60,
|
|
MaxJobsPerDetection: 1,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (h *Handler) Detect(ctx context.Context, request *plugin_pb.RunDetectionRequest, sender pluginworker.DetectionSender) error {
|
|
if request == nil || sender == nil {
|
|
return fmt.Errorf("detect: nil request or sender")
|
|
}
|
|
if request.JobType != "" && request.JobType != jobType {
|
|
return fmt.Errorf("job type %q is not handled by s3 lifecycle handler", request.JobType)
|
|
}
|
|
s3Endpoints := clusterS3Endpoints(request.ClusterContext)
|
|
if len(s3Endpoints) == 0 {
|
|
_ = sender.SendActivity(pluginworker.BuildDetectorActivity("skipped", "no s3 servers registered with master", nil))
|
|
return sender.SendComplete(&plugin_pb.DetectionComplete{JobType: jobType, Success: true})
|
|
}
|
|
filerAddresses := []string{}
|
|
if request.ClusterContext != nil {
|
|
filerAddresses = append(filerAddresses, request.ClusterContext.FilerGrpcAddresses...)
|
|
}
|
|
if len(filerAddresses) == 0 {
|
|
_ = sender.SendActivity(pluginworker.BuildDetectorActivity("skipped", "no filer addresses in cluster context", nil))
|
|
return sender.SendComplete(&plugin_pb.DetectionComplete{JobType: jobType, Success: true})
|
|
}
|
|
|
|
proposal := &plugin_pb.JobProposal{
|
|
JobType: jobType,
|
|
ProposalId: fmt.Sprintf("s3-lifecycle-%d", time.Now().UnixNano()),
|
|
Priority: plugin_pb.JobPriority_JOB_PRIORITY_NORMAL,
|
|
Parameters: map[string]*plugin_pb.ConfigValue{
|
|
"filer_grpc_address": {Kind: &plugin_pb.ConfigValue_StringValue{StringValue: filerAddresses[0]}},
|
|
},
|
|
}
|
|
if err := sender.SendProposals(&plugin_pb.DetectionProposals{
|
|
JobType: jobType,
|
|
Proposals: []*plugin_pb.JobProposal{proposal},
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
return sender.SendComplete(&plugin_pb.DetectionComplete{
|
|
JobType: jobType,
|
|
Success: true,
|
|
TotalProposals: 1,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) Execute(ctx context.Context, request *plugin_pb.ExecuteJobRequest, sender pluginworker.ExecutionSender) error {
|
|
if request == nil || request.Job == nil || sender == nil {
|
|
return fmt.Errorf("execute: nil request/job/sender")
|
|
}
|
|
if request.Job.JobType != "" && request.Job.JobType != jobType {
|
|
return fmt.Errorf("job type %q is not handled by s3 lifecycle handler", request.Job.JobType)
|
|
}
|
|
cfg := ParseConfig(request.GetAdminConfigValues(), request.GetWorkerConfigValues())
|
|
s3Endpoints := clusterS3Endpoints(request.ClusterContext)
|
|
if len(s3Endpoints) == 0 {
|
|
return fmt.Errorf("execute: no s3 servers registered with master")
|
|
}
|
|
filerAddress := readString(request.Job.Parameters, "filer_grpc_address", "")
|
|
if filerAddress == "" {
|
|
return fmt.Errorf("execute: missing filer_grpc_address in job parameters")
|
|
}
|
|
|
|
runCtx, cancel := context.WithTimeout(ctx, cfg.MaxRuntime)
|
|
defer cancel()
|
|
|
|
_ = sender.SendProgress(&plugin_pb.JobProgressUpdate{
|
|
JobId: request.Job.JobId, JobType: jobType,
|
|
State: plugin_pb.JobState_JOB_STATE_RUNNING, Stage: "starting",
|
|
Message: fmt.Sprintf("scheduler workers=%d s3=%v runtime=%s", cfg.Workers, s3Endpoints, cfg.MaxRuntime),
|
|
})
|
|
|
|
dialCtx, dialCancel := context.WithTimeout(runCtx, 30*time.Second)
|
|
filerConn, err := pb.GrpcDial(dialCtx, filerAddress, false, h.grpcDialOption)
|
|
dialCancel()
|
|
if err != nil {
|
|
return fmt.Errorf("dial filer %s: %w", filerAddress, err)
|
|
}
|
|
defer filerConn.Close()
|
|
filerClient := filer_pb.NewSeaweedFilerClient(filerConn)
|
|
|
|
bucketsPath, err := lookupBucketsPath(runCtx, filerClient)
|
|
if err != nil {
|
|
return fmt.Errorf("buckets path: %w", err)
|
|
}
|
|
|
|
dialCtx, dialCancel = context.WithTimeout(runCtx, 30*time.Second)
|
|
s3Conn, err := pb.GrpcDial(dialCtx, s3Endpoints[0], false, h.grpcDialOption)
|
|
dialCancel()
|
|
if err != nil {
|
|
return fmt.Errorf("dial s3 %s: %w", s3Endpoints[0], err)
|
|
}
|
|
defer s3Conn.Close()
|
|
rpc := s3_lifecycle_pb.NewSeaweedS3LifecycleInternalClient(s3Conn)
|
|
|
|
if cfg.Algorithm == AlgorithmDailyReplay {
|
|
return h.executeDailyReplay(runCtx, request, bucketsPath, filerClient, rpc, cfg, sender)
|
|
}
|
|
|
|
sched := &scheduler.Scheduler{
|
|
BucketsPath: bucketsPath,
|
|
Engine: engine.New(),
|
|
Persister: &dispatcher.FilerPersister{Store: dispatcher.NewFilerStoreClient(filerClient)},
|
|
Client: lifecycleRPCAdapter{c: rpc},
|
|
FilerClient: filerClient,
|
|
ClientID: util.RandomInt32(),
|
|
ClientName: "worker-s3-lifecycle",
|
|
Workers: cfg.Workers,
|
|
DispatchTick: cfg.DispatchTick,
|
|
CheckpointTick: cfg.CheckpointTick,
|
|
RefreshInterval: cfg.RefreshInterval,
|
|
BootstrapInterval: cfg.BootstrapInterval,
|
|
}
|
|
if err := sched.Run(runCtx); err != nil {
|
|
glog.Warningf("s3 lifecycle execute: %v", err)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// executeDailyReplay runs the bounded daily-replay path. Reuses the
|
|
// streaming path's filer / s3 / engine setup but routes the per-shard
|
|
// loop through dailyrun.Run instead of scheduler.Scheduler. Phase 2:
|
|
// replay-only, refuses walker-bound action kinds with a typed error.
|
|
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 {
|
|
eng := engine.New()
|
|
inputs, parseErrors, err := scheduler.LoadCompileInputs(ctx, filerClient, bucketsPath)
|
|
if err != nil {
|
|
return 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)
|
|
}
|
|
eng.Compile(inputs, engine.CompileOptions{PriorStates: scheduler.AllActivePriorStates(inputs)})
|
|
|
|
shards := make([]int, 0, cfg.Workers)
|
|
// One pass per shard ID across [0, ShardCount). cfg.Workers governs
|
|
// concurrency, not partitioning — every shard gets exactly one
|
|
// goroutine and the rate.Limiter is the throughput governor.
|
|
for i := 0; i < s3lifecycle.ShardCount; i++ {
|
|
shards = append(shards, i)
|
|
}
|
|
|
|
_ = 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 runtime=%s", len(shards), cfg.MaxRuntime),
|
|
})
|
|
|
|
runErr := dailyrun.Run(ctx, dailyrun.Config{
|
|
Shards: shards,
|
|
BucketsPath: bucketsPath,
|
|
Engine: eng,
|
|
FilerClient: filerClient,
|
|
Client: lifecycleRPCAdapter{c: rpc},
|
|
Persister: &dailyrun.FilerCursorPersister{Store: dispatcher.NewFilerStoreClient(filerClient)},
|
|
Lister: dispatcher.NewFilerSiblingLister(filerClient, bucketsPath),
|
|
Workers: cfg.Workers,
|
|
ClientName: "worker-s3-lifecycle-daily",
|
|
// Limiter is wired in Phase 3 from ClusterContext.Metadata.
|
|
})
|
|
if dailyrun.IsUnsupportedRule(runErr) {
|
|
// Surface the typed error verbatim so admin marks the run as
|
|
// failed with the user-facing reason in the activity log.
|
|
glog.Warningf("daily_replay: %v", runErr)
|
|
return runErr
|
|
}
|
|
if runErr != nil {
|
|
glog.Warningf("daily_replay: %v", runErr)
|
|
return runErr
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// clusterS3Endpoints returns the master-discovered S3 gRPC addresses for the
|
|
// cluster. The handler dials the first reachable one; the master refreshes
|
|
// the list on KeepConnected so a stale entry self-heals on the next run.
|
|
func clusterS3Endpoints(cc *plugin_pb.ClusterContext) []string {
|
|
if cc == nil {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(cc.S3GrpcAddresses))
|
|
for _, addr := range cc.S3GrpcAddresses {
|
|
if addr != "" {
|
|
out = append(out, addr)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
type lifecycleRPCAdapter struct {
|
|
c s3_lifecycle_pb.SeaweedS3LifecycleInternalClient
|
|
}
|
|
|
|
func (a lifecycleRPCAdapter) LifecycleDelete(ctx context.Context, req *s3_lifecycle_pb.LifecycleDeleteRequest) (*s3_lifecycle_pb.LifecycleDeleteResponse, error) {
|
|
return a.c.LifecycleDelete(ctx, req)
|
|
}
|
|
|
|
func lookupBucketsPath(ctx context.Context, client filer_pb.SeaweedFilerClient) (string, error) {
|
|
resp, err := client.GetFilerConfiguration(ctx, &filer_pb.GetFilerConfigurationRequest{})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if path := resp.GetDirBuckets(); path != "" {
|
|
return path, nil
|
|
}
|
|
return "/buckets", nil
|
|
}
|
|
|
|
func readString(values map[string]*plugin_pb.ConfigValue, field, fallback string) string {
|
|
v, ok := values[field]
|
|
if !ok || v == nil {
|
|
return fallback
|
|
}
|
|
if k, ok := v.Kind.(*plugin_pb.ConfigValue_StringValue); ok {
|
|
return k.StringValue
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
var _ pluginworker.JobHandler = (*Handler)(nil)
|