mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-04 11:07:35 +01:00
* admin: keep a reconnected plugin worker registered when its old stream unwinds cleanupSession removed whatever session was registered under the worker ID, so a worker that reconnected while its previous stream was still unwinding lost the new session and its registry entry to the old stream's deferred cleanup. Tie cleanup to the stream that is ending and publish the new stream before the registry upsert so a lingering predecessor sees itself superseded. * admin: fail an in-flight detection when its worker disconnects RunDetectionWithReport waited only on the caller's context and the DetectionComplete channel, so a detection whose worker stream ended mid-request could only finish by timeout. On a customer cluster a planned restart of ec-vacuum-1 during an ec_vacuum detection left the default lane in 'detecting' with every execution slot idle until the 3600s detection timeout, blocking every job type in that lane. Bind the request to the stream it was sent on and fail the detection as soon as that stream closes, whether by disconnect or by a reconnect that replaced the session. A liveness tick also fails it once the registry no longer considers the worker available (heartbeat stale), sending a cancel in that case because the stream may still be open. The detector lease is dropped so the next pick starts fresh, and a 'disconnected' detector activity is recorded. WorkerDisconnectForTest now goes through the real session cleanup. * admin: retry a scheduled detection once when its detector drops When a detection fails because its worker disconnected, pick a detector again and retry once within the same iteration while the job type's runtime budget allows, instead of giving the whole cycle up. Any other failure, or a failed retry, ends the run as before and the lane moves on; the next cycle re-detects anyway. * admin: register a plugin worker's hello and stream as one step WorkerStream published the stream under sessionsMu but ran the registry upsert after releasing it, so two concurrent streams for one worker ID could publish their hellos in the opposite order from their sessions: the registry then described the older hello's capabilities while only the newer stream was sendable, and detector picks routed on them. registerSession upserts the hello and installs the stream under one sessionsMu critical section, and a hello or heartbeat arriving on a stream that is no longer the registered one is dropped instead of rewriting the live stream's entry. * admin: steer a detection retry off the stream that dropped The retry picked while the lost worker could still be in the registry: cleanupSession closed done before taking the registry entry down, and a stream closed by a reconnect keeps its entry on purpose. The lost worker ranks first on free slots, so the single retry went back to it and failed on send while a healthy worker sat idle. The disconnect error now carries the stream it came from and the retry picks around it; pickDetector only returns workers whose current stream is open, excluding registry entries whose stream is closed; and cleanupSession removes the registry entry before closing done, so a waiter woken by the closure never sees the dead worker as a candidate.
1051 lines
31 KiB
Go
1051 lines
31 KiB
Go
package plugin
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
|
"google.golang.org/protobuf/types/known/timestamppb"
|
|
)
|
|
|
|
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)
|
|
}
|
|
if policy.LocalQueuePin != defaultLocalQueuePin {
|
|
t.Fatalf("unexpected local queue pin: got=%d", policy.LocalQueuePin)
|
|
}
|
|
if policy.OrphanRehomeGrace != defaultOrphanRehomeGrace {
|
|
t.Fatalf("unexpected orphan rehome grace: got=%v", policy.OrphanRehomeGrace)
|
|
}
|
|
}
|
|
|
|
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, nil)
|
|
if err != nil {
|
|
t.Fatalf("reserve executor 1: %v", err)
|
|
}
|
|
defer release1()
|
|
|
|
executor2, release2, err := pluginSvc.reserveScheduledExecutor(context.Background(), "balance", policy, nil)
|
|
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, nil)
|
|
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, nil)
|
|
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, nil)
|
|
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.putSession(&streamSession{workerID: "worker-a", outgoing: make(chan *plugin_pb.AdminToWorkerMessage, 1), done: make(chan struct{})})
|
|
pluginSvc.putSession(&streamSession{workerID: "worker-b", outgoing: make(chan *plugin_pb.AdminToWorkerMessage, 1), done: make(chan struct{})})
|
|
pluginSvc.setDetectorLease("vacuum", "worker-b")
|
|
|
|
detector, err := pluginSvc.pickDetector("vacuum", nil)
|
|
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.putSession(&streamSession{workerID: "worker-a", outgoing: make(chan *plugin_pb.AdminToWorkerMessage, 1), done: make(chan struct{})})
|
|
pluginSvc.setDetectorLease("vacuum", "worker-stale")
|
|
|
|
detector, err := pluginSvc.pickDetector("vacuum", nil)
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestScheduledAttemptContextDrainGrace(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assertDeadline := func(t *testing.T, ctx context.Context, want time.Time) {
|
|
t.Helper()
|
|
got, ok := ctx.Deadline()
|
|
if !ok {
|
|
t.Fatal("expected attempt context to carry a deadline")
|
|
}
|
|
if diff := got.Sub(want); diff < -5*time.Second || diff > 5*time.Second {
|
|
t.Fatalf("unexpected attempt deadline: got=%v want~%v", got, want)
|
|
}
|
|
}
|
|
|
|
windowEnd := time.Now().Add(time.Minute)
|
|
window, cancelWindow := context.WithDeadline(context.Background(), windowEnd)
|
|
defer cancelWindow()
|
|
|
|
// Fits inside the window: keeps its own deadline.
|
|
ctx, cancel := scheduledAttemptContext(window, 30*time.Second, 0)
|
|
assertDeadline(t, ctx, time.Now().Add(30*time.Second))
|
|
cancel()
|
|
|
|
// Longer: capped at window close plus the grace.
|
|
ctx, cancel = scheduledAttemptContext(window, 3*time.Hour, 0)
|
|
assertDeadline(t, ctx, windowEnd.Add(scheduledExecutionDrainGrace))
|
|
cancel()
|
|
|
|
// Estimated runtime: keeps its full deadline.
|
|
ctx, cancel = scheduledAttemptContext(window, 3*time.Hour, 3*time.Hour)
|
|
assertDeadline(t, ctx, time.Now().Add(3*time.Hour))
|
|
cancel()
|
|
|
|
// An estimate below the timeout floors the drain cap.
|
|
ctx, cancel = scheduledAttemptContext(window, 3*time.Hour, 45*time.Minute)
|
|
assertDeadline(t, ctx, time.Now().Add(45*time.Minute))
|
|
cancel()
|
|
|
|
// No window deadline: the timeout stands alone.
|
|
ctx, cancel = scheduledAttemptContext(context.Background(), 2*time.Hour, 0)
|
|
assertDeadline(t, ctx, time.Now().Add(2*time.Hour))
|
|
cancel()
|
|
}
|
|
|
|
func TestScheduledEstimatedRuntime(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
estimate := func(v int64) map[string]*plugin_pb.ConfigValue {
|
|
return map[string]*plugin_pb.ConfigValue{
|
|
"estimated_runtime_seconds": {Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: v}},
|
|
}
|
|
}
|
|
|
|
if got := scheduledEstimatedRuntime(nil); got != 0 {
|
|
t.Fatalf("nil parameters: got=%v want=0", got)
|
|
}
|
|
if got := scheduledEstimatedRuntime(estimate(-5)); got != 0 {
|
|
t.Fatalf("negative estimate: got=%v want=0", got)
|
|
}
|
|
if got := scheduledEstimatedRuntime(estimate(600)); got != 10*time.Minute {
|
|
t.Fatalf("normal estimate: got=%v want=10m", got)
|
|
}
|
|
// Oversized seconds cap before the Duration conversion can overflow.
|
|
if got := scheduledEstimatedRuntime(estimate(math.MaxInt64)); got != maxEstimatedRuntimeCap {
|
|
t.Fatalf("oversized estimate: got=%v want=%v", got, maxEstimatedRuntimeCap)
|
|
}
|
|
}
|
|
|
|
func TestDispatchScheduledProposalsDrainsStartedJobOnWindowClose(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pluginSvc, err := New(Options{})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
defer pluginSvc.Shutdown()
|
|
|
|
const workerID = "worker-drain"
|
|
const jobType = "s3_lifecycle" // lifecycle lane: no admin lock needed
|
|
pluginSvc.registry.UpsertFromHello(&plugin_pb.WorkerHello{
|
|
WorkerId: workerID,
|
|
Capabilities: []*plugin_pb.JobTypeCapability{
|
|
{JobType: jobType, CanExecute: true, MaxExecutionConcurrency: 1},
|
|
},
|
|
})
|
|
session := &streamSession{workerID: workerID, outgoing: make(chan *plugin_pb.AdminToWorkerMessage, 8), done: make(chan struct{})}
|
|
pluginSvc.putSession(session)
|
|
|
|
windowCtx, closeWindow := context.WithCancel(context.Background())
|
|
defer closeWindow()
|
|
|
|
policy := schedulerPolicy{
|
|
ExecutionConcurrency: 1,
|
|
PerWorkerConcurrency: 1,
|
|
ExecutionTimeout: time.Hour,
|
|
ExecutorReserveBackoff: time.Millisecond,
|
|
}
|
|
proposals := []*plugin_pb.JobProposal{
|
|
{ProposalId: "p1", JobType: jobType, DedupeKey: "k1"},
|
|
{ProposalId: "p2", JobType: jobType, DedupeKey: "k2"},
|
|
}
|
|
|
|
resultCh := make(chan [3]int, 1)
|
|
go func() {
|
|
success, errCount, canceled := pluginSvc.dispatchScheduledProposals(
|
|
windowCtx, jobType, proposals, &plugin_pb.ClusterContext{}, policy)
|
|
resultCh <- [3]int{success, errCount, canceled}
|
|
}()
|
|
|
|
first := <-session.outgoing
|
|
execReq := first.GetExecuteJobRequest()
|
|
if execReq == nil {
|
|
t.Fatalf("expected execute_job_request, got %+v", first)
|
|
}
|
|
|
|
// Close the window mid-job: the job must drain to completion while the
|
|
// still-queued second proposal is canceled.
|
|
closeWindow()
|
|
pluginSvc.handleJobCompleted(&plugin_pb.JobCompleted{
|
|
RequestId: first.RequestId,
|
|
JobId: execReq.Job.JobId,
|
|
JobType: jobType,
|
|
Success: true,
|
|
CompletedAt: timestamppb.Now(),
|
|
})
|
|
|
|
result := <-resultCh
|
|
if result[0] != 1 || result[1] != 0 || result[2] != 1 {
|
|
t.Fatalf("unexpected dispatch counts: success=%d errors=%d canceled=%d", result[0], result[1], result[2])
|
|
}
|
|
|
|
select {
|
|
case unexpected := <-session.outgoing:
|
|
t.Fatalf("expected no further worker messages (no cancel for the draining job), got %+v", unexpected)
|
|
default:
|
|
}
|
|
}
|
|
|
|
// ---------- 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)
|
|
}
|
|
}
|
|
|
|
func TestReserveWaterfillsPreferredHosts(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pluginSvc, err := New(Options{})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
defer pluginSvc.Shutdown()
|
|
|
|
ids := []string{"worker-a", "worker-b", "worker-c", "worker-d"}
|
|
for _, id := range ids {
|
|
pluginSvc.registry.UpsertFromHello(&plugin_pb.WorkerHello{
|
|
WorkerId: id,
|
|
Address: "10.0.0.1:9334",
|
|
Capabilities: []*plugin_pb.JobTypeCapability{
|
|
{JobType: "erasure_coding", CanExecute: true, MaxExecutionConcurrency: 8},
|
|
},
|
|
})
|
|
}
|
|
|
|
policy := schedulerPolicy{
|
|
PerWorkerConcurrency: 8,
|
|
ExecutorReserveBackoff: time.Millisecond,
|
|
LocalQueuePin: 5,
|
|
}
|
|
preferred := []string{"10.0.0.1:8080"}
|
|
|
|
seen := map[string]int{}
|
|
var releases []func()
|
|
defer func() {
|
|
for _, release := range releases {
|
|
release()
|
|
}
|
|
}()
|
|
for i := 0; i < 4; i++ {
|
|
exec, release, err := pluginSvc.reserveScheduledExecutor(context.Background(), "erasure_coding", policy, preferred)
|
|
if err != nil {
|
|
t.Fatalf("reserve %d: %v", i, err)
|
|
}
|
|
releases = append(releases, release)
|
|
seen[exec.WorkerID]++
|
|
}
|
|
if len(seen) != 4 {
|
|
t.Fatalf("want one job per worker, got %v", seen)
|
|
}
|
|
}
|