mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-08-16 20:26:45 +00:00
plugin: stop the scheduler lock test racing its own background loops TestRunLaneSchedulerIterationLockBehavior constructed the plugin with a cluster-context provider, which makes New start a background scheduler loop per lane. Those loops call runLaneSchedulerIteration on the same lane the test then drives by hand, so a loop could consume the due job — running detection and pushing the next-detection time forward — before the manual call observed the lock. The Default case then saw the lock acquired zero times and failed intermittently. Construct without the provider so no loops start, and set the provider afterward so the manual iteration can still detect. This is the pattern scheduler_status_test.go already uses for the same reason. Reproduced under -race -count=100 -p 4 before, green after. Claude-Session: https://claude.ai/code/session_01Ks16jnt4S7gdDk8cheQ3xu
855 lines
25 KiB
Go
855 lines
25 KiB
Go
package plugin
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
|
)
|
|
|
|
func TestLoadSchedulerPolicyUsesAdminConfig(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pluginSvc, err := New(Options{})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
defer pluginSvc.Shutdown()
|
|
|
|
err = pluginSvc.SaveJobTypeConfig(&plugin_pb.PersistedJobTypeConfig{
|
|
JobType: "vacuum",
|
|
AdminRuntime: &plugin_pb.AdminRuntimeConfig{
|
|
Enabled: true,
|
|
DetectionIntervalMinutes: 30,
|
|
DetectionTimeoutSeconds: 20,
|
|
MaxJobsPerDetection: 123,
|
|
GlobalExecutionConcurrency: 5,
|
|
PerWorkerExecutionConcurrency: 2,
|
|
RetryLimit: 4,
|
|
RetryBackoffSeconds: 7,
|
|
JobTypeMaxRuntimeSeconds: 1800,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SaveJobTypeConfig: %v", err)
|
|
}
|
|
|
|
policy, enabled, err := pluginSvc.loadSchedulerPolicy("vacuum")
|
|
if err != nil {
|
|
t.Fatalf("loadSchedulerPolicy: %v", err)
|
|
}
|
|
if !enabled {
|
|
t.Fatalf("expected enabled policy")
|
|
}
|
|
if policy.MaxResults != 123 {
|
|
t.Fatalf("unexpected max results: got=%d", policy.MaxResults)
|
|
}
|
|
if policy.ExecutionConcurrency != 5 {
|
|
t.Fatalf("unexpected global concurrency: got=%d", policy.ExecutionConcurrency)
|
|
}
|
|
if policy.PerWorkerConcurrency != 2 {
|
|
t.Fatalf("unexpected per-worker concurrency: got=%d", policy.PerWorkerConcurrency)
|
|
}
|
|
if policy.RetryLimit != 4 {
|
|
t.Fatalf("unexpected retry limit: got=%d", policy.RetryLimit)
|
|
}
|
|
if policy.JobTypeMaxRuntime != 30*time.Minute {
|
|
t.Fatalf("unexpected max runtime: got=%v", policy.JobTypeMaxRuntime)
|
|
}
|
|
}
|
|
|
|
func TestLoadSchedulerPolicyUsesDescriptorDefaultsWhenConfigMissing(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pluginSvc, err := New(Options{})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
defer pluginSvc.Shutdown()
|
|
|
|
err = pluginSvc.store.SaveDescriptor("ec", &plugin_pb.JobTypeDescriptor{
|
|
JobType: "ec",
|
|
AdminRuntimeDefaults: &plugin_pb.AdminRuntimeDefaults{
|
|
Enabled: true,
|
|
DetectionIntervalMinutes: 60,
|
|
DetectionTimeoutSeconds: 25,
|
|
MaxJobsPerDetection: 30,
|
|
GlobalExecutionConcurrency: 4,
|
|
PerWorkerExecutionConcurrency: 2,
|
|
RetryLimit: 3,
|
|
RetryBackoffSeconds: 6,
|
|
JobTypeMaxRuntimeSeconds: 1200,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SaveDescriptor: %v", err)
|
|
}
|
|
|
|
policy, enabled, err := pluginSvc.loadSchedulerPolicy("ec")
|
|
if err != nil {
|
|
t.Fatalf("loadSchedulerPolicy: %v", err)
|
|
}
|
|
if !enabled {
|
|
t.Fatalf("expected enabled policy from descriptor defaults")
|
|
}
|
|
if policy.MaxResults != 30 {
|
|
t.Fatalf("unexpected max results: got=%d", policy.MaxResults)
|
|
}
|
|
if policy.ExecutionConcurrency != 4 {
|
|
t.Fatalf("unexpected global concurrency: got=%d", policy.ExecutionConcurrency)
|
|
}
|
|
if policy.PerWorkerConcurrency != 2 {
|
|
t.Fatalf("unexpected per-worker concurrency: got=%d", policy.PerWorkerConcurrency)
|
|
}
|
|
if policy.JobTypeMaxRuntime != 20*time.Minute {
|
|
t.Fatalf("unexpected max runtime: got=%v", policy.JobTypeMaxRuntime)
|
|
}
|
|
}
|
|
|
|
func TestReserveScheduledExecutorRespectsPerWorkerLimit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pluginSvc, err := New(Options{})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
defer pluginSvc.Shutdown()
|
|
|
|
pluginSvc.registry.UpsertFromHello(&plugin_pb.WorkerHello{
|
|
WorkerId: "worker-a",
|
|
Capabilities: []*plugin_pb.JobTypeCapability{
|
|
{JobType: "balance", CanExecute: true, MaxExecutionConcurrency: 4},
|
|
},
|
|
})
|
|
pluginSvc.registry.UpsertFromHello(&plugin_pb.WorkerHello{
|
|
WorkerId: "worker-b",
|
|
Capabilities: []*plugin_pb.JobTypeCapability{
|
|
{JobType: "balance", CanExecute: true, MaxExecutionConcurrency: 2},
|
|
},
|
|
})
|
|
|
|
policy := schedulerPolicy{
|
|
PerWorkerConcurrency: 1,
|
|
ExecutorReserveBackoff: time.Millisecond,
|
|
}
|
|
|
|
executor1, release1, err := pluginSvc.reserveScheduledExecutor(context.Background(), "balance", policy)
|
|
if err != nil {
|
|
t.Fatalf("reserve executor 1: %v", err)
|
|
}
|
|
defer release1()
|
|
|
|
executor2, release2, err := pluginSvc.reserveScheduledExecutor(context.Background(), "balance", policy)
|
|
if err != nil {
|
|
t.Fatalf("reserve executor 2: %v", err)
|
|
}
|
|
defer release2()
|
|
|
|
if executor1.WorkerID == executor2.WorkerID {
|
|
t.Fatalf("expected different executors due per-worker limit, got same worker %s", executor1.WorkerID)
|
|
}
|
|
}
|
|
|
|
func TestFilterScheduledProposalsDedupe(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pluginSvc, err := New(Options{})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
defer pluginSvc.Shutdown()
|
|
|
|
proposals := []*plugin_pb.JobProposal{
|
|
{ProposalId: "p1", DedupeKey: "d1"},
|
|
{ProposalId: "p2", DedupeKey: "d1"}, // same dedupe key
|
|
{ProposalId: "p3", DedupeKey: "d3"},
|
|
{ProposalId: "p3"}, // fallback dedupe by proposal id
|
|
{ProposalId: "p4"},
|
|
{ProposalId: "p4"}, // same proposal id, no dedupe key
|
|
}
|
|
|
|
filtered := pluginSvc.filterScheduledProposals(proposals)
|
|
if len(filtered) != 4 {
|
|
t.Fatalf("unexpected filtered size: got=%d want=4", len(filtered))
|
|
}
|
|
|
|
filtered2 := pluginSvc.filterScheduledProposals(proposals)
|
|
if len(filtered2) != 4 {
|
|
t.Fatalf("expected second run dedupe to be per-run only, got=%d", len(filtered2))
|
|
}
|
|
}
|
|
|
|
func TestBuildScheduledJobSpecDoesNotReuseProposalID(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
proposal := &plugin_pb.JobProposal{
|
|
ProposalId: "vacuum-2",
|
|
DedupeKey: "vacuum:2",
|
|
JobType: "vacuum",
|
|
}
|
|
|
|
jobA := buildScheduledJobSpec("vacuum", proposal, 0)
|
|
jobB := buildScheduledJobSpec("vacuum", proposal, 1)
|
|
|
|
if jobA.JobId == proposal.ProposalId {
|
|
t.Fatalf("scheduled job id must not reuse proposal id: %s", jobA.JobId)
|
|
}
|
|
if jobB.JobId == proposal.ProposalId {
|
|
t.Fatalf("scheduled job id must not reuse proposal id: %s", jobB.JobId)
|
|
}
|
|
if jobA.JobId == jobB.JobId {
|
|
t.Fatalf("scheduled job ids must be unique across jobs: %s", jobA.JobId)
|
|
}
|
|
}
|
|
|
|
func TestFilterProposalsWithActiveJobs(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pluginSvc, err := New(Options{})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
defer pluginSvc.Shutdown()
|
|
|
|
pluginSvc.trackExecutionStart("req-1", "worker-a", &plugin_pb.JobSpec{
|
|
JobId: "job-1",
|
|
JobType: "vacuum",
|
|
DedupeKey: "vacuum:k1",
|
|
}, 1)
|
|
pluginSvc.trackExecutionStart("req-2", "worker-b", &plugin_pb.JobSpec{
|
|
JobId: "job-2",
|
|
JobType: "vacuum",
|
|
}, 1)
|
|
pluginSvc.trackExecutionQueued(&plugin_pb.JobSpec{
|
|
JobId: "job-3",
|
|
JobType: "vacuum",
|
|
DedupeKey: "vacuum:k4",
|
|
})
|
|
|
|
filtered, skipped := pluginSvc.filterProposalsWithActiveJobs("vacuum", []*plugin_pb.JobProposal{
|
|
{ProposalId: "proposal-1", JobType: "vacuum", DedupeKey: "vacuum:k1"},
|
|
{ProposalId: "job-2", JobType: "vacuum"},
|
|
{ProposalId: "proposal-2b", JobType: "vacuum", DedupeKey: "vacuum:k4"},
|
|
{ProposalId: "proposal-3", JobType: "vacuum", DedupeKey: "vacuum:k3"},
|
|
{ProposalId: "proposal-4", JobType: "balance", DedupeKey: "balance:k1"},
|
|
})
|
|
if skipped != 3 {
|
|
t.Fatalf("unexpected skipped count: got=%d want=3", skipped)
|
|
}
|
|
if len(filtered) != 2 {
|
|
t.Fatalf("unexpected filtered size: got=%d want=2", len(filtered))
|
|
}
|
|
if filtered[0].ProposalId != "proposal-3" || filtered[1].ProposalId != "proposal-4" {
|
|
t.Fatalf("unexpected filtered proposals: got=%s,%s", filtered[0].ProposalId, filtered[1].ProposalId)
|
|
}
|
|
}
|
|
|
|
func TestReserveScheduledExecutorTimesOutWhenNoExecutor(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pluginSvc, err := New(Options{})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
defer pluginSvc.Shutdown()
|
|
|
|
policy := schedulerPolicy{
|
|
ExecutionTimeout: 30 * time.Millisecond,
|
|
ExecutorReserveBackoff: 5 * time.Millisecond,
|
|
PerWorkerConcurrency: 1,
|
|
}
|
|
|
|
start := time.Now()
|
|
pluginSvc.Shutdown()
|
|
_, _, err = pluginSvc.reserveScheduledExecutor(context.Background(), "missing-job-type", policy)
|
|
if err == nil {
|
|
t.Fatalf("expected reservation shutdown error")
|
|
}
|
|
if time.Since(start) > 50*time.Millisecond {
|
|
t.Fatalf("reservation returned too late after shutdown: duration=%v", time.Since(start))
|
|
}
|
|
}
|
|
|
|
func TestReserveScheduledExecutorWaitsForWorkerCapacity(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pluginSvc, err := New(Options{})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
defer pluginSvc.Shutdown()
|
|
|
|
pluginSvc.registry.UpsertFromHello(&plugin_pb.WorkerHello{
|
|
WorkerId: "worker-a",
|
|
Capabilities: []*plugin_pb.JobTypeCapability{
|
|
{JobType: "balance", CanExecute: true, MaxExecutionConcurrency: 1},
|
|
},
|
|
})
|
|
|
|
policy := schedulerPolicy{
|
|
ExecutionTimeout: time.Second,
|
|
PerWorkerConcurrency: 8,
|
|
ExecutorReserveBackoff: 5 * time.Millisecond,
|
|
}
|
|
|
|
_, release1, err := pluginSvc.reserveScheduledExecutor(context.Background(), "balance", policy)
|
|
if err != nil {
|
|
t.Fatalf("reserve executor 1: %v", err)
|
|
}
|
|
defer release1()
|
|
|
|
type reserveResult struct {
|
|
err error
|
|
}
|
|
secondReserveCh := make(chan reserveResult, 1)
|
|
go func() {
|
|
_, release2, reserveErr := pluginSvc.reserveScheduledExecutor(context.Background(), "balance", policy)
|
|
if release2 != nil {
|
|
release2()
|
|
}
|
|
secondReserveCh <- reserveResult{err: reserveErr}
|
|
}()
|
|
|
|
select {
|
|
case result := <-secondReserveCh:
|
|
t.Fatalf("expected second reservation to wait for capacity, got=%v", result.err)
|
|
case <-time.After(25 * time.Millisecond):
|
|
// Expected: still waiting.
|
|
}
|
|
|
|
release1()
|
|
|
|
select {
|
|
case result := <-secondReserveCh:
|
|
if result.err != nil {
|
|
t.Fatalf("second reservation error: %v", result.err)
|
|
}
|
|
case <-time.After(200 * time.Millisecond):
|
|
t.Fatalf("second reservation did not acquire after capacity release")
|
|
}
|
|
}
|
|
|
|
func TestShouldSkipDetectionForWaitingJobs(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pluginSvc, err := New(Options{})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
defer pluginSvc.Shutdown()
|
|
|
|
policy := schedulerPolicy{
|
|
ExecutionConcurrency: 2,
|
|
MaxResults: 100,
|
|
}
|
|
threshold := waitingBacklogThreshold(policy)
|
|
if threshold <= 0 {
|
|
t.Fatalf("expected positive waiting threshold")
|
|
}
|
|
|
|
for i := 0; i < threshold; i++ {
|
|
pluginSvc.trackExecutionQueued(&plugin_pb.JobSpec{
|
|
JobId: fmt.Sprintf("job-waiting-%d", i),
|
|
JobType: "vacuum",
|
|
DedupeKey: fmt.Sprintf("vacuum:%d", i),
|
|
})
|
|
}
|
|
|
|
skip, waitingCount, waitingThreshold := pluginSvc.shouldSkipDetectionForWaitingJobs("vacuum", policy)
|
|
if !skip {
|
|
t.Fatalf("expected detection to skip when waiting backlog reaches threshold")
|
|
}
|
|
if waitingCount != threshold {
|
|
t.Fatalf("unexpected waiting count: got=%d want=%d", waitingCount, threshold)
|
|
}
|
|
if waitingThreshold != threshold {
|
|
t.Fatalf("unexpected waiting threshold: got=%d want=%d", waitingThreshold, threshold)
|
|
}
|
|
}
|
|
|
|
func TestWaitingBacklogThresholdHonorsMaxResultsCap(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
policy := schedulerPolicy{
|
|
ExecutionConcurrency: 8,
|
|
MaxResults: 6,
|
|
}
|
|
threshold := waitingBacklogThreshold(policy)
|
|
if threshold != 6 {
|
|
t.Fatalf("expected threshold to be capped by max results, got=%d", threshold)
|
|
}
|
|
}
|
|
|
|
func TestListSchedulerStatesIncludesPolicyAndState(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pluginSvc, err := New(Options{})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
defer pluginSvc.Shutdown()
|
|
|
|
const jobType = "vacuum"
|
|
err = pluginSvc.SaveJobTypeConfig(&plugin_pb.PersistedJobTypeConfig{
|
|
JobType: jobType,
|
|
AdminRuntime: &plugin_pb.AdminRuntimeConfig{
|
|
Enabled: true,
|
|
DetectionIntervalMinutes: 45,
|
|
DetectionTimeoutSeconds: 30,
|
|
MaxJobsPerDetection: 80,
|
|
GlobalExecutionConcurrency: 3,
|
|
PerWorkerExecutionConcurrency: 2,
|
|
RetryLimit: 1,
|
|
RetryBackoffSeconds: 9,
|
|
JobTypeMaxRuntimeSeconds: 900,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SaveJobTypeConfig: %v", err)
|
|
}
|
|
|
|
pluginSvc.registry.UpsertFromHello(&plugin_pb.WorkerHello{
|
|
WorkerId: "worker-a",
|
|
Capabilities: []*plugin_pb.JobTypeCapability{
|
|
{JobType: jobType, CanDetect: true, CanExecute: true},
|
|
},
|
|
})
|
|
|
|
nextDetectionAt := time.Now().UTC().Add(2 * time.Minute).Round(time.Second)
|
|
pluginSvc.schedulerMu.Lock()
|
|
pluginSvc.nextDetectionAt[jobType] = nextDetectionAt
|
|
pluginSvc.detectionInFlight[jobType] = true
|
|
pluginSvc.schedulerMu.Unlock()
|
|
|
|
states, err := pluginSvc.ListSchedulerStates()
|
|
if err != nil {
|
|
t.Fatalf("ListSchedulerStates: %v", err)
|
|
}
|
|
|
|
state := findSchedulerState(states, jobType)
|
|
if state == nil {
|
|
t.Fatalf("missing scheduler state for %s", jobType)
|
|
}
|
|
if !state.Enabled {
|
|
t.Fatalf("expected enabled scheduler state")
|
|
}
|
|
if state.PolicyError != "" {
|
|
t.Fatalf("unexpected policy error: %s", state.PolicyError)
|
|
}
|
|
if !state.DetectionInFlight {
|
|
t.Fatalf("expected detection in flight")
|
|
}
|
|
if state.NextDetectionAt == nil {
|
|
t.Fatalf("expected next detection time")
|
|
}
|
|
if state.NextDetectionAt.Unix() != nextDetectionAt.Unix() {
|
|
t.Fatalf("unexpected next detection time: got=%v want=%v", state.NextDetectionAt, nextDetectionAt)
|
|
}
|
|
if state.DetectionIntervalMinutes != 45 {
|
|
t.Fatalf("unexpected detection interval: got=%d", state.DetectionIntervalMinutes)
|
|
}
|
|
if state.DetectionTimeoutSeconds != 30 {
|
|
t.Fatalf("unexpected detection timeout: got=%d", state.DetectionTimeoutSeconds)
|
|
}
|
|
if state.ExecutionTimeoutSeconds != 90 {
|
|
t.Fatalf("unexpected execution timeout: got=%d", state.ExecutionTimeoutSeconds)
|
|
}
|
|
if state.JobTypeMaxRuntimeSeconds != 900 {
|
|
t.Fatalf("unexpected job type max runtime: got=%d", state.JobTypeMaxRuntimeSeconds)
|
|
}
|
|
if state.MaxJobsPerDetection != 80 {
|
|
t.Fatalf("unexpected max jobs per detection: got=%d", state.MaxJobsPerDetection)
|
|
}
|
|
if state.GlobalExecutionConcurrency != 3 {
|
|
t.Fatalf("unexpected global execution concurrency: got=%d", state.GlobalExecutionConcurrency)
|
|
}
|
|
if state.PerWorkerExecutionConcurrency != 2 {
|
|
t.Fatalf("unexpected per worker execution concurrency: got=%d", state.PerWorkerExecutionConcurrency)
|
|
}
|
|
if state.RetryLimit != 1 {
|
|
t.Fatalf("unexpected retry limit: got=%d", state.RetryLimit)
|
|
}
|
|
if state.RetryBackoffSeconds != 9 {
|
|
t.Fatalf("unexpected retry backoff: got=%d", state.RetryBackoffSeconds)
|
|
}
|
|
if !state.DetectorAvailable || state.DetectorWorkerID != "worker-a" {
|
|
t.Fatalf("unexpected detector assignment: available=%v worker=%s", state.DetectorAvailable, state.DetectorWorkerID)
|
|
}
|
|
if state.ExecutorWorkerCount != 1 {
|
|
t.Fatalf("unexpected executor worker count: got=%d", state.ExecutorWorkerCount)
|
|
}
|
|
}
|
|
|
|
func TestListSchedulerStatesShowsDisabledWhenNoPolicy(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pluginSvc, err := New(Options{})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
defer pluginSvc.Shutdown()
|
|
|
|
const jobType = "balance"
|
|
pluginSvc.registry.UpsertFromHello(&plugin_pb.WorkerHello{
|
|
WorkerId: "worker-b",
|
|
Capabilities: []*plugin_pb.JobTypeCapability{
|
|
{JobType: jobType, CanDetect: true, CanExecute: true},
|
|
},
|
|
})
|
|
|
|
states, err := pluginSvc.ListSchedulerStates()
|
|
if err != nil {
|
|
t.Fatalf("ListSchedulerStates: %v", err)
|
|
}
|
|
|
|
state := findSchedulerState(states, jobType)
|
|
if state == nil {
|
|
t.Fatalf("missing scheduler state for %s", jobType)
|
|
}
|
|
if state.Enabled {
|
|
t.Fatalf("expected disabled scheduler state")
|
|
}
|
|
if state.PolicyError != "" {
|
|
t.Fatalf("unexpected policy error: %s", state.PolicyError)
|
|
}
|
|
if !state.DetectorAvailable || state.DetectorWorkerID != "worker-b" {
|
|
t.Fatalf("unexpected detector details: available=%v worker=%s", state.DetectorAvailable, state.DetectorWorkerID)
|
|
}
|
|
if state.ExecutorWorkerCount != 1 {
|
|
t.Fatalf("unexpected executor worker count: got=%d", state.ExecutorWorkerCount)
|
|
}
|
|
}
|
|
|
|
func findSchedulerState(states []SchedulerJobTypeState, jobType string) *SchedulerJobTypeState {
|
|
for i := range states {
|
|
if states[i].JobType == jobType {
|
|
return &states[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func TestPickDetectorPrefersLeasedWorker(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pluginSvc, err := New(Options{})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
defer pluginSvc.Shutdown()
|
|
|
|
pluginSvc.registry.UpsertFromHello(&plugin_pb.WorkerHello{
|
|
WorkerId: "worker-a",
|
|
Capabilities: []*plugin_pb.JobTypeCapability{
|
|
{JobType: "vacuum", CanDetect: true},
|
|
},
|
|
})
|
|
pluginSvc.registry.UpsertFromHello(&plugin_pb.WorkerHello{
|
|
WorkerId: "worker-b",
|
|
Capabilities: []*plugin_pb.JobTypeCapability{
|
|
{JobType: "vacuum", CanDetect: true},
|
|
},
|
|
})
|
|
|
|
pluginSvc.setDetectorLease("vacuum", "worker-b")
|
|
|
|
detector, err := pluginSvc.pickDetector("vacuum")
|
|
if err != nil {
|
|
t.Fatalf("pickDetector: %v", err)
|
|
}
|
|
if detector.WorkerID != "worker-b" {
|
|
t.Fatalf("expected leased detector worker-b, got=%s", detector.WorkerID)
|
|
}
|
|
}
|
|
|
|
func TestPickDetectorReassignsWhenLeaseIsStale(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pluginSvc, err := New(Options{})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
defer pluginSvc.Shutdown()
|
|
|
|
pluginSvc.registry.UpsertFromHello(&plugin_pb.WorkerHello{
|
|
WorkerId: "worker-a",
|
|
Capabilities: []*plugin_pb.JobTypeCapability{
|
|
{JobType: "vacuum", CanDetect: true},
|
|
},
|
|
})
|
|
pluginSvc.setDetectorLease("vacuum", "worker-stale")
|
|
|
|
detector, err := pluginSvc.pickDetector("vacuum")
|
|
if err != nil {
|
|
t.Fatalf("pickDetector: %v", err)
|
|
}
|
|
if detector.WorkerID != "worker-a" {
|
|
t.Fatalf("expected reassigned detector worker-a, got=%s", detector.WorkerID)
|
|
}
|
|
|
|
lease := pluginSvc.getDetectorLease("vacuum")
|
|
if lease != "worker-a" {
|
|
t.Fatalf("expected detector lease to be updated to worker-a, got=%s", lease)
|
|
}
|
|
}
|
|
|
|
// trackingLockManager records whether Acquire was called and how many times.
|
|
type trackingLockManager struct {
|
|
mu sync.Mutex
|
|
acquired int
|
|
}
|
|
|
|
func (m *trackingLockManager) Acquire(reason string) (func(), error) {
|
|
m.mu.Lock()
|
|
m.acquired++
|
|
m.mu.Unlock()
|
|
return func() {}, nil
|
|
}
|
|
|
|
func (m *trackingLockManager) count() int {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.acquired
|
|
}
|
|
|
|
func TestRunLaneSchedulerIterationLockBehavior(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
lane SchedulerLane
|
|
jobType string
|
|
wantLock bool
|
|
}{
|
|
{"Default", LaneDefault, "vacuum", true},
|
|
{"Iceberg", LaneIceberg, "iceberg_maintenance", false},
|
|
{"Lifecycle", LaneLifecycle, "s3_lifecycle", false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
lm := &trackingLockManager{}
|
|
// Construct without a cluster-context provider so the background
|
|
// lane loops do not start: they call runLaneSchedulerIteration on
|
|
// the same lane and would race this test's own manual call,
|
|
// consuming the due job before it observes the lock. The provider
|
|
// is set afterward so the manual iteration can still detect.
|
|
pluginSvc, err := New(Options{LockManager: lm})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
defer pluginSvc.Shutdown()
|
|
pluginSvc.clusterContextProvider = func(context.Context) (*plugin_pb.ClusterContext, error) {
|
|
return &plugin_pb.ClusterContext{}, nil
|
|
}
|
|
|
|
// Register a detectable worker for the job type.
|
|
pluginSvc.registry.UpsertFromHello(&plugin_pb.WorkerHello{
|
|
WorkerId: "worker-a",
|
|
Capabilities: []*plugin_pb.JobTypeCapability{
|
|
{JobType: tt.jobType, CanDetect: true},
|
|
},
|
|
})
|
|
|
|
// Enable the job type so the scheduler picks it up.
|
|
err = pluginSvc.SaveJobTypeConfig(&plugin_pb.PersistedJobTypeConfig{
|
|
JobType: tt.jobType,
|
|
AdminRuntime: &plugin_pb.AdminRuntimeConfig{
|
|
Enabled: true,
|
|
DetectionIntervalMinutes: 1,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SaveJobTypeConfig: %v", err)
|
|
}
|
|
|
|
// Make the job type due immediately so the iteration reaches
|
|
// detection; the lock is only taken around due work now.
|
|
pluginSvc.schedulerMu.Lock()
|
|
pluginSvc.nextDetectionAt[tt.jobType] = time.Now().UTC().Add(-time.Second)
|
|
pluginSvc.schedulerMu.Unlock()
|
|
|
|
ls := pluginSvc.lanes[tt.lane]
|
|
pluginSvc.runLaneSchedulerIteration(ls)
|
|
|
|
if got := lm.count(); (got > 0) != tt.wantLock {
|
|
t.Errorf("lock acquired %d times, wantLock=%v", got, tt.wantLock)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDispatchScheduledProposalsLocksPerJob(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
jobType string
|
|
wantLocks int
|
|
}{
|
|
{"DefaultLaneLocksEachJob", "volume_balance", 3},
|
|
{"LifecycleLaneNeedsNoLock", "s3_lifecycle", 0},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
lm := &trackingLockManager{}
|
|
pluginSvc, err := New(Options{LockManager: lm})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
defer pluginSvc.Shutdown()
|
|
|
|
// The worker has capabilities but no connected session, so each
|
|
// job reserves capacity, fails to send, and moves on.
|
|
pluginSvc.registry.UpsertFromHello(&plugin_pb.WorkerHello{
|
|
WorkerId: "worker-a",
|
|
Capabilities: []*plugin_pb.JobTypeCapability{
|
|
{JobType: tt.jobType, CanExecute: true, MaxExecutionConcurrency: 4},
|
|
},
|
|
})
|
|
|
|
policy := schedulerPolicy{
|
|
ExecutionConcurrency: 1,
|
|
PerWorkerConcurrency: 1,
|
|
ExecutionTimeout: time.Second,
|
|
ExecutorReserveBackoff: time.Millisecond,
|
|
}
|
|
proposals := []*plugin_pb.JobProposal{
|
|
{ProposalId: "p1", JobType: tt.jobType, DedupeKey: "k1"},
|
|
{ProposalId: "p2", JobType: tt.jobType, DedupeKey: "k2"},
|
|
{ProposalId: "p3", JobType: tt.jobType, DedupeKey: "k3"},
|
|
}
|
|
|
|
success, errors, canceled := pluginSvc.dispatchScheduledProposals(
|
|
context.Background(), tt.jobType, proposals, &plugin_pb.ClusterContext{}, policy)
|
|
if success != 0 || canceled != 0 || errors != 3 {
|
|
t.Fatalf("unexpected dispatch counts: success=%d errors=%d canceled=%d", success, errors, canceled)
|
|
}
|
|
if got := lm.count(); got != tt.wantLocks {
|
|
t.Fatalf("lock acquired %d times, want %d", got, tt.wantLocks)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------- lane-scoped prune ----------
|
|
|
|
func TestPruneSchedulerState_DefaultLaneKeepsForeignLanesAndPrunesOwnStale(t *testing.T) {
|
|
p, err := New(Options{})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
defer p.Shutdown()
|
|
|
|
now := time.Now().UTC()
|
|
p.schedulerMu.Lock()
|
|
p.nextDetectionAt["s3_lifecycle"] = now.Add(24 * time.Hour) // lifecycle lane
|
|
p.nextDetectionAt["vacuum"] = now.Add(time.Minute) // default lane, active
|
|
p.nextDetectionAt["ec_balance"] = now.Add(time.Minute) // default lane, stale
|
|
p.detectionInFlight["ec_balance"] = true
|
|
p.schedulerMu.Unlock()
|
|
|
|
// Default-lane iteration prunes with only its own active job types.
|
|
p.pruneSchedulerState(LaneDefault, map[string]struct{}{"vacuum": {}})
|
|
|
|
p.schedulerMu.Lock()
|
|
defer p.schedulerMu.Unlock()
|
|
if _, ok := p.nextDetectionAt["s3_lifecycle"]; !ok {
|
|
t.Fatal("default-lane prune must not delete lifecycle-lane nextDetectionAt[s3_lifecycle]")
|
|
}
|
|
if _, ok := p.nextDetectionAt["vacuum"]; !ok {
|
|
t.Fatal("active default-lane job (vacuum) must be kept")
|
|
}
|
|
if _, ok := p.nextDetectionAt["ec_balance"]; ok {
|
|
t.Fatal("stale default-lane job (ec_balance) must still be pruned within its own lane")
|
|
}
|
|
if _, ok := p.detectionInFlight["ec_balance"]; ok {
|
|
t.Fatal("pruned job must also drop its detectionInFlight entry")
|
|
}
|
|
}
|
|
|
|
func TestPruneSchedulerState_LifecycleLaneLeavesDefaultLane(t *testing.T) {
|
|
p, err := New(Options{})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
defer p.Shutdown()
|
|
|
|
now := time.Now().UTC()
|
|
p.schedulerMu.Lock()
|
|
p.nextDetectionAt["vacuum"] = now.Add(time.Minute) // default lane
|
|
p.nextDetectionAt["s3_lifecycle"] = now.Add(24 * time.Hour) // lifecycle lane, active
|
|
p.schedulerMu.Unlock()
|
|
|
|
p.pruneSchedulerState(LaneLifecycle, map[string]struct{}{"s3_lifecycle": {}})
|
|
|
|
p.schedulerMu.Lock()
|
|
defer p.schedulerMu.Unlock()
|
|
if _, ok := p.nextDetectionAt["vacuum"]; !ok {
|
|
t.Fatal("lifecycle-lane prune must not delete default-lane nextDetectionAt[vacuum]")
|
|
}
|
|
if _, ok := p.nextDetectionAt["s3_lifecycle"]; !ok {
|
|
t.Fatal("active lifecycle job (s3_lifecycle) must be kept")
|
|
}
|
|
}
|
|
|
|
func TestPruneDetectorLeases_IsLaneScoped(t *testing.T) {
|
|
p, err := New(Options{})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
defer p.Shutdown()
|
|
|
|
p.detectorLeaseMu.Lock()
|
|
p.detectorLeases["s3_lifecycle"] = "worker-a" // lifecycle lane
|
|
p.detectorLeases["vacuum"] = "worker-b" // default lane, active
|
|
p.detectorLeases["ec_balance"] = "worker-c" // default lane, stale
|
|
p.detectorLeaseMu.Unlock()
|
|
|
|
p.pruneDetectorLeases(LaneDefault, map[string]struct{}{"vacuum": {}})
|
|
|
|
p.detectorLeaseMu.Lock()
|
|
defer p.detectorLeaseMu.Unlock()
|
|
if _, ok := p.detectorLeases["s3_lifecycle"]; !ok {
|
|
t.Fatal("default-lane prune must not delete lifecycle-lane detector lease")
|
|
}
|
|
if _, ok := p.detectorLeases["vacuum"]; !ok {
|
|
t.Fatal("active default-lane detector lease (vacuum) must be kept")
|
|
}
|
|
if _, ok := p.detectorLeases["ec_balance"]; ok {
|
|
t.Fatal("stale default-lane detector lease (ec_balance) must still be pruned within its own lane")
|
|
}
|
|
}
|
|
|
|
func TestLaneStatus_LifecycleNextDetectionSurvivesDefaultLanePrune(t *testing.T) {
|
|
p, err := New(Options{})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
defer p.Shutdown()
|
|
|
|
now := time.Now().UTC()
|
|
expected := now.Add(24 * time.Hour)
|
|
p.schedulerMu.Lock()
|
|
p.nextDetectionAt["s3_lifecycle"] = expected
|
|
p.nextDetectionAt["vacuum"] = now.Add(time.Minute)
|
|
p.schedulerMu.Unlock()
|
|
|
|
p.pruneSchedulerState(LaneDefault, map[string]struct{}{"vacuum": {}})
|
|
|
|
status := p.GetLaneSchedulerStatus(LaneLifecycle)
|
|
if status.NextDetectionAt == nil {
|
|
t.Fatal("lifecycle lane status lost next_detection_at after a default-lane prune")
|
|
}
|
|
if !status.NextDetectionAt.Equal(expected) {
|
|
t.Fatalf("next_detection_at = %v, want %v (must be the 24h schedule, not the idle-sleep fallback)",
|
|
status.NextDetectionAt, expected)
|
|
}
|
|
}
|