Skip to content

Commit 8ff43d6

Browse files
agparadisomchain0
authored andcommitted
[CRE-1601] shard-orchestrator implementation (#1747)
* feat: implement shard-orchestrator logic * chore: use state constants instead of strings * test: add pluging <-> shardorchestrator integration tests * tests: add server tests * feat: add client to communicate with shard 0 * chore: remove unused timestamps * fix: remove reduntant check --------- Co-authored-by: mchain0 <maciej.wisniewski@smartcontract.com>
1 parent e5e4627 commit 8ff43d6

17 files changed

Lines changed: 1282 additions & 124 deletions

pkg/workflows/ring/factory.go

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import (
44
"context"
55
"errors"
66

7+
"github.com/smartcontractkit/libocr/offchainreporting2plus/ocr3types"
8+
79
"github.com/smartcontractkit/chainlink-common/pkg/logger"
810
"github.com/smartcontractkit/chainlink-common/pkg/services"
911
"github.com/smartcontractkit/chainlink-common/pkg/types/core"
1012
"github.com/smartcontractkit/chainlink-common/pkg/workflows/ring/pb"
11-
"github.com/smartcontractkit/libocr/offchainreporting2plus/ocr3types"
13+
"github.com/smartcontractkit/chainlink-common/pkg/workflows/shardorchestrator"
1214
)
1315

1416
const (
@@ -20,15 +22,16 @@ const (
2022
var _ core.OCR3ReportingPluginFactory = &Factory{}
2123

2224
type Factory struct {
23-
store *Store
24-
arbiterScaler pb.ArbiterScalerClient
25-
config *ConsensusConfig
26-
lggr logger.Logger
25+
ringStore *Store
26+
shardOrchestratorStore *shardorchestrator.Store
27+
arbiterScaler pb.ArbiterScalerClient
28+
config *ConsensusConfig
29+
lggr logger.Logger
2730

2831
services.StateMachine
2932
}
3033

31-
func NewFactory(s *Store, arbiterScaler pb.ArbiterScalerClient, lggr logger.Logger, cfg *ConsensusConfig) (*Factory, error) {
34+
func NewFactory(s *Store, shardOrchestratorStore *shardorchestrator.Store, arbiterScaler pb.ArbiterScalerClient, lggr logger.Logger, cfg *ConsensusConfig) (*Factory, error) {
3235
if arbiterScaler == nil {
3336
return nil, errors.New("arbiterScaler is required")
3437
}
@@ -38,15 +41,16 @@ func NewFactory(s *Store, arbiterScaler pb.ArbiterScalerClient, lggr logger.Logg
3841
}
3942
}
4043
return &Factory{
41-
store: s,
42-
arbiterScaler: arbiterScaler,
43-
config: cfg,
44-
lggr: logger.Named(lggr, "RingPluginFactory"),
44+
ringStore: s,
45+
shardOrchestratorStore: shardOrchestratorStore,
46+
arbiterScaler: arbiterScaler,
47+
config: cfg,
48+
lggr: logger.Named(lggr, "RingPluginFactory"),
4549
}, nil
4650
}
4751

4852
func (o *Factory) NewReportingPlugin(_ context.Context, config ocr3types.ReportingPluginConfig) (ocr3types.ReportingPlugin[[]byte], ocr3types.ReportingPluginInfo, error) {
49-
plugin, err := NewPlugin(o.store, o.arbiterScaler, config, o.lggr, o.config)
53+
plugin, err := NewPlugin(o.ringStore, o.arbiterScaler, config, o.lggr, o.config)
5054
pluginInfo := ocr3types.ReportingPluginInfo{
5155
Name: "RingPlugin",
5256
Limits: ocr3types.ReportingPluginLimits{

pkg/workflows/ring/factory_test.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,18 @@ import (
44
"context"
55
"testing"
66

7-
"github.com/smartcontractkit/chainlink-common/pkg/logger"
8-
"github.com/smartcontractkit/chainlink-common/pkg/workflows/ring/pb"
97
"github.com/smartcontractkit/libocr/offchainreporting2plus/ocr3types"
108
"github.com/stretchr/testify/require"
9+
10+
"github.com/smartcontractkit/chainlink-common/pkg/logger"
11+
"github.com/smartcontractkit/chainlink-common/pkg/workflows/ring/pb"
12+
"github.com/smartcontractkit/chainlink-common/pkg/workflows/shardorchestrator"
1113
)
1214

1315
func TestFactory_NewFactory(t *testing.T) {
1416
lggr := logger.Test(t)
1517
store := NewStore()
18+
shardOrchestratorStore := shardorchestrator.NewStore(lggr)
1619
arbiter := &mockArbiter{}
1720

1821
tests := []struct {
@@ -45,7 +48,7 @@ func TestFactory_NewFactory(t *testing.T) {
4548

4649
for _, tt := range tests {
4750
t.Run(tt.name, func(t *testing.T) {
48-
f, err := NewFactory(store, tt.arbiter, lggr, tt.config)
51+
f, err := NewFactory(store, shardOrchestratorStore, tt.arbiter, lggr, tt.config)
4952
if tt.wantErr {
5053
require.Error(t, err)
5154
require.Contains(t, err.Error(), tt.errSubstr)
@@ -60,7 +63,7 @@ func TestFactory_NewFactory(t *testing.T) {
6063
func TestFactory_NewReportingPlugin(t *testing.T) {
6164
lggr := logger.Test(t)
6265
store := NewStore()
63-
f, err := NewFactory(store, &mockArbiter{}, lggr, nil)
66+
f, err := NewFactory(store, nil, &mockArbiter{}, lggr, nil)
6467
require.NoError(t, err)
6568

6669
config := ocr3types.ReportingPluginConfig{N: 4, F: 1}
@@ -75,7 +78,7 @@ func TestFactory_NewReportingPlugin(t *testing.T) {
7578
func TestFactory_Lifecycle(t *testing.T) {
7679
lggr := logger.Test(t)
7780
store := NewStore()
78-
f, err := NewFactory(store, &mockArbiter{}, lggr, nil)
81+
f, err := NewFactory(store, nil, &mockArbiter{}, lggr, nil)
7982
require.NoError(t, err)
8083

8184
err = f.Start(context.Background())

pkg/workflows/ring/pb/generate.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
//go:generate protoc --go_out=. --go_opt=paths=source_relative shared.proto
22
//go:generate protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative arbiter.proto
3-
//go:generate protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative shard_orchestrator.proto
43
//go:generate protoc --go_out=. --go_opt=paths=source_relative consensus.proto
54

65
package pb

pkg/workflows/ring/plugin_test.go

Lines changed: 160 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ import (
1212
"google.golang.org/protobuf/types/known/emptypb"
1313
"google.golang.org/protobuf/types/known/timestamppb"
1414

15-
"github.com/smartcontractkit/chainlink-common/pkg/logger"
16-
"github.com/smartcontractkit/chainlink-common/pkg/workflows/ring/pb"
1715
"github.com/smartcontractkit/libocr/offchainreporting2/types"
1816
"github.com/smartcontractkit/libocr/offchainreporting2plus/ocr3types"
17+
18+
"github.com/smartcontractkit/chainlink-common/pkg/logger"
19+
"github.com/smartcontractkit/chainlink-common/pkg/workflows/ring/pb"
20+
"github.com/smartcontractkit/chainlink-common/pkg/workflows/shardorchestrator"
1921
)
2022

2123
type mockArbiter struct {
@@ -443,7 +445,7 @@ func TestPlugin_NoHealthyShardsFallbackToShardZero(t *testing.T) {
443445
})
444446
require.NoError(t, err)
445447

446-
transmitter := NewTransmitter(lggr, store, arbiter, "test-account")
448+
transmitter := NewTransmitter(lggr, store, nil, arbiter, "test-account")
447449

448450
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
449451
defer cancel()
@@ -591,3 +593,158 @@ func TestPlugin_ObservationQuorum(t *testing.T) {
591593
require.True(t, quorum)
592594
})
593595
}
596+
597+
func TestPlugin_ShardOrchestratorIntegration(t *testing.T) {
598+
lggr := logger.Test(t)
599+
600+
// Create both stores
601+
ringStore := NewStore()
602+
orchestratorStore := shardorchestrator.NewStore(lggr)
603+
604+
// Initialize ring store with healthy shards
605+
ringStore.SetAllShardHealth(map[uint32]bool{0: true, 1: true, 2: true})
606+
607+
config := ocr3types.ReportingPluginConfig{
608+
N: 4, F: 1,
609+
}
610+
611+
arbiter := &mockArbiter{}
612+
plugin, err := NewPlugin(ringStore, arbiter, config, lggr, &ConsensusConfig{
613+
BatchSize: 100,
614+
TimeToSync: 1 * time.Second,
615+
})
616+
require.NoError(t, err)
617+
618+
// Create transmitter with both stores
619+
transmitter := NewTransmitter(lggr, ringStore, orchestratorStore, arbiter, "test-account")
620+
621+
ctx := context.Background()
622+
now := time.Now()
623+
624+
t.Run("initial_workflow_assignments", func(t *testing.T) {
625+
// Create observations with workflows
626+
workflows := []string{"wf-A", "wf-B", "wf-C"}
627+
aos := makeObservationsWithWantShards(t, []map[uint32]*pb.ShardStatus{
628+
{0: {IsHealthy: true}, 1: {IsHealthy: true}, 2: {IsHealthy: true}},
629+
{0: {IsHealthy: true}, 1: {IsHealthy: true}, 2: {IsHealthy: true}},
630+
{0: {IsHealthy: true}, 1: {IsHealthy: true}, 2: {IsHealthy: true}},
631+
}, workflows, now, 3)
632+
633+
outcomeCtx := ocr3types.OutcomeContext{
634+
SeqNr: 1,
635+
PreviousOutcome: nil,
636+
}
637+
638+
// Generate outcome
639+
outcome, err := plugin.Outcome(ctx, outcomeCtx, nil, aos)
640+
require.NoError(t, err)
641+
642+
// Generate report and transmit
643+
reports, err := plugin.Reports(ctx, 1, outcome)
644+
require.NoError(t, err)
645+
require.Len(t, reports, 1)
646+
647+
err = transmitter.Transmit(ctx, types.ConfigDigest{}, 1, reports[0].ReportWithInfo, nil)
648+
require.NoError(t, err)
649+
650+
// Verify ring store was updated
651+
for _, wf := range workflows {
652+
shard, err := ringStore.GetShardForWorkflow(ctx, wf)
653+
require.NoError(t, err)
654+
require.LessOrEqual(t, shard, uint32(2), "workflow should be assigned to valid shard")
655+
t.Logf("Ring store: %s → shard %d", wf, shard)
656+
}
657+
658+
// Verify orchestrator store was updated with correct state
659+
for _, wf := range workflows {
660+
mapping, err := orchestratorStore.GetWorkflowMapping(ctx, wf)
661+
require.NoError(t, err)
662+
require.Equal(t, wf, mapping.WorkflowID)
663+
require.LessOrEqual(t, mapping.NewShardID, uint32(2))
664+
require.Equal(t, uint32(0), mapping.OldShardID, "initial assignment should have oldShardID=0")
665+
require.Equal(t, shardorchestrator.StateSteady, mapping.TransitionState, "initial assignment should be steady")
666+
t.Logf("Orchestrator store: %s → shard %d (state: %s)", wf, mapping.NewShardID, mapping.TransitionState.String())
667+
}
668+
669+
// Verify version tracking
670+
version := orchestratorStore.GetMappingVersion()
671+
require.Equal(t, uint64(1), version, "version should increment after first update")
672+
})
673+
674+
t.Run("workflow_transition_detected", func(t *testing.T) {
675+
// First, establish a baseline with workflows distributed across 3 shards
676+
// Use wantShards=3 to ensure workflows actually get assigned to shard 2
677+
baselineAos := makeObservationsWithWantShards(t, []map[uint32]*pb.ShardStatus{
678+
{0: {IsHealthy: true}, 1: {IsHealthy: true}, 2: {IsHealthy: true}},
679+
{0: {IsHealthy: true}, 1: {IsHealthy: true}, 2: {IsHealthy: true}},
680+
{0: {IsHealthy: true}, 1: {IsHealthy: true}, 2: {IsHealthy: true}},
681+
}, []string{"wf-A", "wf-B", "wf-C", "wf-D", "wf-E"}, now, 3)
682+
683+
baselineOutcome, err := plugin.Outcome(ctx, ocr3types.OutcomeContext{SeqNr: 2}, nil, baselineAos)
684+
require.NoError(t, err)
685+
686+
baselineReports, err := plugin.Reports(ctx, 2, baselineOutcome)
687+
require.NoError(t, err)
688+
689+
err = transmitter.Transmit(ctx, types.ConfigDigest{}, 2, baselineReports[0].ReportWithInfo, nil)
690+
require.NoError(t, err)
691+
692+
// Parse baseline to see which workflows were on shard 2
693+
baselineProto := &pb.Outcome{}
694+
err = proto.Unmarshal(baselineOutcome, baselineProto)
695+
require.NoError(t, err)
696+
697+
workflowsOnShard2 := []string{}
698+
for wfID, route := range baselineProto.Routes {
699+
if route.Shard == 2 {
700+
workflowsOnShard2 = append(workflowsOnShard2, wfID)
701+
}
702+
t.Logf("Baseline: %s on shard %d", wfID, route.Shard)
703+
}
704+
require.NotEmpty(t, workflowsOnShard2, "at least one workflow should be on shard 2 for this test")
705+
706+
// Now scale down to 2 shards - workflows on shard 2 MUST move
707+
transitionAos := makeObservationsWithWantShards(t, []map[uint32]*pb.ShardStatus{
708+
{0: {IsHealthy: true}, 1: {IsHealthy: true}},
709+
{0: {IsHealthy: true}, 1: {IsHealthy: true}},
710+
{0: {IsHealthy: true}, 1: {IsHealthy: true}},
711+
}, []string{"wf-A", "wf-B", "wf-C", "wf-D", "wf-E"}, now, 2)
712+
713+
outcomeCtx := ocr3types.OutcomeContext{
714+
SeqNr: 3,
715+
PreviousOutcome: baselineOutcome,
716+
}
717+
718+
outcome, err := plugin.Outcome(ctx, outcomeCtx, nil, transitionAos)
719+
require.NoError(t, err)
720+
721+
reports, err := plugin.Reports(ctx, 3, outcome)
722+
require.NoError(t, err)
723+
724+
err = transmitter.Transmit(ctx, types.ConfigDigest{}, 3, reports[0].ReportWithInfo, nil)
725+
require.NoError(t, err)
726+
727+
// Verify orchestrator store shows transition state for workflows that moved from shard 2
728+
outcomeProto := &pb.Outcome{}
729+
err = proto.Unmarshal(outcome, outcomeProto)
730+
require.NoError(t, err)
731+
732+
// Workflows that were on shard 2 must have moved and should show TransitionState
733+
for _, wfID := range workflowsOnShard2 {
734+
mapping, err := orchestratorStore.GetWorkflowMapping(ctx, wfID)
735+
require.NoError(t, err)
736+
737+
newRoute := outcomeProto.Routes[wfID]
738+
require.NotEqual(t, uint32(2), newRoute.Shard, "workflow should have moved from shard 2")
739+
require.Equal(t, shardorchestrator.StateTransitioning, mapping.TransitionState,
740+
"workflow %s moved from shard 2 to shard %d, should be transitioning", wfID, newRoute.Shard)
741+
require.Equal(t, uint32(2), mapping.OldShardID, "should track old shard")
742+
require.Equal(t, newRoute.Shard, mapping.NewShardID, "should track new shard")
743+
t.Logf("Workflow %s transitioned: shard 2 → %d", wfID, newRoute.Shard)
744+
}
745+
746+
// Verify version incremented
747+
version := orchestratorStore.GetMappingVersion()
748+
require.Equal(t, uint64(3), version, "version should increment after update")
749+
})
750+
}

pkg/workflows/ring/state.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,25 @@ import (
77
"google.golang.org/protobuf/types/known/timestamppb"
88

99
"github.com/smartcontractkit/chainlink-common/pkg/workflows/ring/pb"
10+
"github.com/smartcontractkit/chainlink-common/pkg/workflows/shardorchestrator"
1011
)
1112

13+
// TransitionStateFromBool converts a proto bool (in_transition) to TransitionState
14+
func TransitionStateFromBool(inTransition bool) shardorchestrator.TransitionState {
15+
if inTransition {
16+
return shardorchestrator.StateTransitioning
17+
}
18+
return shardorchestrator.StateSteady
19+
}
20+
21+
// TransitionStateFromRoutingState returns the TransitionState based on RoutingState
22+
func TransitionStateFromRoutingState(state *pb.RoutingState) shardorchestrator.TransitionState {
23+
if IsInSteadyState(state) {
24+
return shardorchestrator.StateSteady
25+
}
26+
return shardorchestrator.StateTransitioning
27+
}
28+
1229
func IsInSteadyState(state *pb.RoutingState) bool {
1330
if state == nil {
1431
return false

0 commit comments

Comments
 (0)