Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 48 additions & 31 deletions pkg/service/coordinator/allocation.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,19 @@ import (

"github.com/google/uuid"

"go.atoms.co/splitter/lib/service/location"
"go.atoms.co/lib/mapx"
"go.atoms.co/slicex"
"go.atoms.co/lib/uuidx"
"go.atoms.co/slicex"
"go.atoms.co/splitter/lib/service/location"
splitterpb "go.atoms.co/splitter/pb"
"go.atoms.co/splitter/pkg/allocation"
"go.atoms.co/splitter/pkg/core"
"go.atoms.co/splitter/pkg/model"
splitteruuidx "go.atoms.co/splitter/pkg/util/uuidx"
splitterpb "go.atoms.co/splitter/pb"
)

const (
defaultShardLoad = allocation.Load(50)
defaultShardLoad = allocation.Load(defaultShardScore)
unitShardLoad = defaultShardLoad * 5
regionAffinityLoad = defaultShardLoad * 2
namedShardsLoad = defaultShardLoad * 2
Expand All @@ -39,12 +39,12 @@ type (

// TODO(herohde) 11/12/2023: intra-domain anti-affinity to spread out domains evenly. Similar to general LB.

func newAllocation(id location.InstanceID, tenant model.TenantInfo, info model.ServiceInfoEx, placements []core.InternalPlacementInfo, activation time.Time) *Allocation {
return allocation.New(id, findPlacements(tenant, info), findColocations(info), findWork(info, placements), activation)
func newAllocation(id location.InstanceID, tenant model.TenantInfo, info model.ServiceInfoEx, placements []core.InternalPlacementInfo, trackers map[model.QualifiedDomainName]*domainLoadTracker, activation time.Time) *Allocation {
return allocation.New(id, findPlacements(tenant, info), findColocations(info), findWork(info, placements, trackers), activation)
}

func updateAllocation(a *Allocation, tenant model.TenantInfo, info model.ServiceInfoEx, namedShards []model.Shard, placements []core.InternalPlacementInfo, activation time.Time) (*Allocation, []Grant) {
return allocation.Update(a, findPlacements(tenant, info, namedShards...), findColocations(info), findWork(info, placements), activation)
func updateAllocation(a *Allocation, tenant model.TenantInfo, info model.ServiceInfoEx, namedShards []model.Shard, placements []core.InternalPlacementInfo, trackers map[model.QualifiedDomainName]*domainLoadTracker, activation time.Time) (*Allocation, []Grant) {
return allocation.Update(a, findPlacements(tenant, info, namedShards...), findColocations(info), findWork(info, placements, trackers), activation)
}

// NamedShards handles named shard placement
Expand Down Expand Up @@ -248,7 +248,7 @@ func findColocations(info model.ServiceInfoEx) []Colocation {
return slicex.New[Colocation](a)
}

func findWork(state model.ServiceInfoEx, placements []core.InternalPlacementInfo) []Work {
func findWork(state model.ServiceInfoEx, placements []core.InternalPlacementInfo, trackers map[model.QualifiedDomainName]*domainLoadTracker) []Work {
var ret []Work

m := mapx.New(placements, func(v core.InternalPlacementInfo) model.PlacementName {
Expand All @@ -260,6 +260,8 @@ func findWork(state model.ServiceInfoEx, placements []core.InternalPlacementInfo
for _, domain := range state.Domains() {
switch domain.Type() {
case model.Unit:
// TODO: (xuhui) 07/29/2026 Support load-aware allocation for unit domains.
// Unit domains currently use a fixed load and are excluded from load balancing.
locations := toLocations(domain.Regions()...)
if len(locations) == 0 {
locations = defaultLocations
Expand Down Expand Up @@ -293,15 +295,16 @@ func findWork(state model.ServiceInfoEx, placements []core.InternalPlacementInfo
for _, shard := range shards {
region := provider.Find(model.Key(shard.From()))

unit := model.Shard{
Domain: domain.Name(),
Type: model.Global,
From: model.Key(shard.From()),
To: model.Key(shard.To()),
}
w := Work{
Unit: model.Shard{
Domain: domain.Name(),
Type: model.Global,
From: model.Key(shard.From()),
To: model.Key(shard.To()),
},
Unit: unit,
Data: slicex.New(location.Location{Region: region}),
Load: defaultShardLoad,
Load: shardWorkLoad(state, trackers, unit),
}
ret = append(ret, w)
}
Expand All @@ -312,15 +315,16 @@ func findWork(state model.ServiceInfoEx, placements []core.InternalPlacementInfo
locations = defaultLocations
}
for _, shard := range shards {
unit := model.Shard{
Domain: domain.Name(),
Type: model.Global,
From: model.Key(shard.From()),
To: model.Key(shard.To()),
}
w := Work{
Unit: model.Shard{
Domain: domain.Name(),
Type: model.Global,
From: model.Key(shard.From()),
To: model.Key(shard.To()),
},
Unit: unit,
Data: locations,
Load: defaultShardLoad,
Load: shardWorkLoad(state, trackers, unit),
}
ret = append(ret, w)
}
Expand All @@ -331,16 +335,17 @@ func findWork(state model.ServiceInfoEx, placements []core.InternalPlacementInfo
shards := findShardsForRegion(domain, region)

for _, shard := range shards {
unit := model.Shard{
Region: region,
Type: model.Regional,
Domain: domain.Name(),
From: model.Key(shard.From()),
To: model.Key(shard.To()),
}
ret = append(ret, Work{
Unit: model.Shard{
Region: region,
Type: model.Regional,
Domain: domain.Name(),
From: model.Key(shard.From()),
To: model.Key(shard.To()),
},
Unit: unit,
Data: slicex.New(location.Location{Region: region}),
Load: defaultShardLoad,
Load: shardWorkLoad(state, trackers, unit),
})
}
}
Expand All @@ -353,6 +358,18 @@ func findWork(state model.ServiceInfoEx, placements []core.InternalPlacementInfo
return ret
}

func shardWorkLoad(state model.ServiceInfoEx, trackers map[model.QualifiedDomainName]*domainLoadTracker, shard model.Shard) allocation.Load {
if !state.Service().Config().TrackLoad() {
return defaultShardLoad
}

shardScore := defaultShardScore
if tracker, ok := trackers[shard.Domain]; ok {
shardScore = tracker.shardScoreOrDefault(core.NewShard(shard.From, shard.To, shard.Region))
}
return allocation.Load(max(1, shardScore))
}

func findShards(domain model.Domain) []uuidx.Range {
policy := domain.Config().ShardingPolicy()
targetShardCount := policy.Shards()
Expand Down
82 changes: 79 additions & 3 deletions pkg/service/coordinator/allocation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"go.atoms.co/splitter/lib/service/location"
"go.atoms.co/lib/testing/assertx"
"go.atoms.co/slicex"
"go.atoms.co/lib/uuidx"
"go.atoms.co/slicex"
"go.atoms.co/splitter/lib/service/location"
splitterpb "go.atoms.co/splitter/pb"
"go.atoms.co/splitter/pkg/allocation"
"go.atoms.co/splitter/pkg/core"
"go.atoms.co/splitter/pkg/model"
splitterpb "go.atoms.co/splitter/pb"
"go.atoms.co/splitter/testing/prefab"
)

Expand Down Expand Up @@ -143,6 +144,81 @@ func TestNamedShards(t *testing.T) {
assert.Equal(t, namedShardsLoad, load)
}

func TestFindWork_LoadScores(t *testing.T) {
serviceName := model.QualifiedServiceName{Tenant: "tenant", Service: "service"}

newInfo := func(t *testing.T, domainType model.DomainType, cfg model.ServiceConfig, domainOpts ...model.DomainOption) model.ServiceInfoEx {
t.Helper()

service, err := model.NewService(serviceName, time.Time{}, model.WithServiceConfig(cfg))
require.NoError(t, err)
domain, err := model.NewDomain(model.QualifiedDomainName{Service: serviceName, Domain: "domain"}, domainType, time.Time{}, domainOpts...)
require.NoError(t, err)
return model.NewServiceInfoEx(model.NewServiceInfo(service, 1, time.Time{}), []model.Domain{domain})
}

globalDomainOpts := []model.DomainOption{model.WithDomainConfig(model.NewDomainConfig(model.WithDomainShardingPolicy(model.NewShardingPolicy(1))))}

t.Run("tracking disabled", func(t *testing.T) {
info := newInfo(t, model.Global, model.NewServiceConfig(), globalDomainOpts...)
work := findWork(info, nil, nil)
require.Len(t, work, 1)
require.Equal(t, defaultShardLoad, work[0].Load)
})

t.Run("score unavailable", func(t *testing.T) {
info := newInfo(t, model.Global, model.NewServiceConfig(model.WithTrackLoad(true)), globalDomainOpts...)
work := findWork(info, nil, nil)
require.Len(t, work, 1)
require.Equal(t, allocation.Load(defaultShardScore), work[0].Load)
})

t.Run("published global score", func(t *testing.T) {
info := newInfo(t, model.Global, model.NewServiceConfig(model.WithTrackLoad(true)), globalDomainOpts...)
initial := findWork(info, nil, nil)
require.Len(t, initial, 1)
shard := initial[0].Unit
tracker := newDomainLoadTracker(time.Time{}, shard.Domain.Domain)
tracker.quantile = &domainQuantileInfo{
domainQuantile: 20,
shardQuantiles: map[core.Shard]float64{
core.NewShard(shard.From, shard.To, shard.Region): 60,
},
}

work := findWork(info, nil, map[model.QualifiedDomainName]*domainLoadTracker{shard.Domain: tracker})
require.Len(t, work, 1)
require.Equal(t, allocation.Load(75), work[0].Load)
})

t.Run("published regional score includes region", func(t *testing.T) {
domainCfg := model.NewDomainConfig(model.WithDomainShardingPolicy(model.NewShardingPolicy(1)), model.WithDomainRegions("centralus"))
info := newInfo(t, model.Regional, model.NewServiceConfig(model.WithTrackLoad(true)), model.WithDomainConfig(domainCfg))
initial := findWork(info, nil, nil)
require.Len(t, initial, 1)
shard := initial[0].Unit
tracker := newDomainLoadTracker(time.Time{}, shard.Domain.Domain)
tracker.quantile = &domainQuantileInfo{
domainQuantile: 20,
shardQuantiles: map[core.Shard]float64{
core.NewShard(shard.From, shard.To, ""): 1,
core.NewShard(shard.From, shard.To, shard.Region): 60,
},
}

work := findWork(info, nil, map[model.QualifiedDomainName]*domainLoadTracker{shard.Domain: tracker})
require.Len(t, work, 1)
require.Equal(t, allocation.Load(75), work[0].Load)
})

t.Run("unit load unchanged", func(t *testing.T) {
info := newInfo(t, model.Unit, model.NewServiceConfig(model.WithTrackLoad(true)))
work := findWork(info, nil, nil)
require.Len(t, work, 1)
require.Equal(t, unitShardLoad, work[0].Load)
})
}

func TestDomainState(t *testing.T) {
t1, err := model.NewTenant("tenant1", time.Time{})
require.NoError(t, err)
Expand Down
31 changes: 24 additions & 7 deletions pkg/service/coordinator/coordinator.go
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,7 @@ func (c *coordinator) connect(ctx context.Context, sid session.ID, origin locati
lease := now.Add(leaseDuration)
s.TrySend(ctx, model.NewExtend(lease)) // grants will be covered under this lease

//TODO: (xuhui) 07/27/2026 revisit impact to services that set capacity.
capacity := allocation.Load(int64(limit) * int64(defaultShardLoad)) // Set capacity shard limit * shard load (0 for no capacity)
if capacity > 0 {
log.Infof(ctx, "Consumer %v connected with non-zero capacity limit: %v", consumer, capacity)
Expand Down Expand Up @@ -542,12 +543,12 @@ func (c *coordinator) init(ctx context.Context, state core.State, updates <-chan
}

now := time.Now()
c.alloc = newAllocation(c.self.ID(), tenant, info, c.cache.Placements(c.name.Tenant), now.Add(delay))
c.restoreLoadTrackers(ctx)

c.alloc = newAllocation(c.self.ID(), tenant, info, c.cache.Placements(c.name.Tenant), c.trackers, now.Add(delay))
c.noLb = c.findUnitDomains()
c.cluster = model.NewClusterMap(model.NewClusterID(c.self, now), c.alloc.Units())

c.restoreLoadTrackers(ctx)

log.Infof(ctx, "Coordinator %v/%v initialized, #shards=%v", c.name, c.self, c.alloc.Size())
c.recordAction(ctx, "init", "ok")
c.recordActionLatency(ctx, "init", start)
Expand Down Expand Up @@ -629,6 +630,7 @@ steady:
// (1) Refresh allocation, (2) allocate, (3) broadcast cluster change

now := time.Now()
trackLoad := c.info.Service().Config().TrackLoad()

if err := c.cache.Update(upd, false); err != nil {
log.Errorf(ctx, "Internal: invalid state update %v", err)
Expand All @@ -649,6 +651,10 @@ steady:
}
c.info = info

if trackLoad && !info.Service().Config().TrackLoad() {
clear(c.trackers)
}

oldShards := c.alloc.Units()

c.refresh(ctx, c.refreshDelay)
Expand Down Expand Up @@ -707,9 +713,7 @@ steady:
break
}

for _, t := range c.trackers {
t.rotateIfNeeded(now)
}
c.rotateTrackerAndRefreshIfNeeded(ctx, now)

// (1) emit domain load and shard load metrics
c.emitLoadMetrics(ctx)
Expand Down Expand Up @@ -755,6 +759,19 @@ steady:
log.Infof(ctx, "Coordinator %v draining, #consumer=%v", c.self, len(c.consumers))
}

func (c *coordinator) rotateTrackerAndRefreshIfNeeded(ctx context.Context, now time.Time) {
var updated bool
for _, t := range c.trackers {
updated = t.rotateIfNeeded(now) || updated
}

if !updated {
return
}

c.refresh(ctx, c.refreshDelay)
}

func (c *coordinator) refresh(ctx context.Context, delay time.Duration) {
now := time.Now()

Expand Down Expand Up @@ -784,7 +801,7 @@ func (c *coordinator) refresh(ctx context.Context, delay time.Duration) {
}
}

upd, rejected := updateAllocation(c.alloc, c.tenant, c.info, namedShards, c.cache.Placements(c.name.Tenant), now.Add(delay))
upd, rejected := updateAllocation(c.alloc, c.tenant, c.info, namedShards, c.cache.Placements(c.name.Tenant), c.trackers, now.Add(delay))
c.alloc = upd
c.noLb = c.findUnitDomains()

Expand Down
Loading