Skip to content

Commit c331ad9

Browse files
authored
feat(p2p): add versioned handlers function (#5548)
1 parent a404d60 commit c331ad9

3 files changed

Lines changed: 449 additions & 0 deletions

File tree

pkg/p2p/versioned/example_test.go

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
// Copyright 2026 The Swarm Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package versioned_test
6+
7+
import (
8+
"context"
9+
"fmt"
10+
11+
"github.com/coreos/go-semver/semver"
12+
"github.com/ethersphere/bee/v2/pkg/metrics"
13+
"github.com/ethersphere/bee/v2/pkg/p2p"
14+
"github.com/ethersphere/bee/v2/pkg/p2p/protobuf"
15+
"github.com/ethersphere/bee/v2/pkg/p2p/versioned"
16+
"github.com/ethersphere/bee/v2/pkg/swarm"
17+
"github.com/prometheus/client_golang/prometheus"
18+
)
19+
20+
const (
21+
exampleProtocolName = "versionedping"
22+
exampleProtocolVersion = "1.2.0"
23+
exampleStreamName = "ping"
24+
)
25+
26+
type exampleMetrics struct {
27+
HandledStreamVersionCount *prometheus.CounterVec
28+
}
29+
30+
func newExampleMetrics() exampleMetrics {
31+
return exampleMetrics{
32+
HandledStreamVersionCount: prometheus.NewCounterVec(
33+
prometheus.CounterOpts{
34+
Namespace: metrics.Namespace,
35+
Subsystem: exampleProtocolName,
36+
Name: "handled_stream_version_total",
37+
Help: "Number of handled streams by protocol version.",
38+
},
39+
[]string{"version"},
40+
),
41+
}
42+
}
43+
44+
// ExampleService represents a full Bee protocol service (structured like pkg/pingpong)
45+
// supporting 3 version levels:
46+
// - v1.2.0: Current version
47+
// - v1.1.0: Legacy version 1.1
48+
// - v1.0.0: Legacy version 1.0
49+
type ExampleService struct {
50+
streamer p2p.Streamer
51+
metrics exampleMetrics
52+
}
53+
54+
func NewExampleService(streamer p2p.Streamer) *ExampleService {
55+
return &ExampleService{
56+
streamer: streamer,
57+
metrics: newExampleMetrics(),
58+
}
59+
}
60+
61+
func (s *ExampleService) Metrics() []prometheus.Collector {
62+
return metrics.PrometheusCollectorsFromFields(s.metrics)
63+
}
64+
65+
func (s *ExampleService) Protocol() p2p.ProtocolSpec {
66+
return p2p.ProtocolSpec{
67+
Name: exampleProtocolName,
68+
Version: exampleProtocolVersion,
69+
StreamSpecs: []p2p.StreamSpec{
70+
{
71+
Name: exampleStreamName,
72+
Handler: versioned.NewHandlersFunc(
73+
[]versioned.Handler{
74+
{
75+
Version: semver.New("1.2.0"), // Server handler for >= 1.2.0
76+
Handler: func(ctx context.Context, p p2p.Peer, stream p2p.Stream) error {
77+
w, r := protobuf.NewWriterAndReader(stream)
78+
_, _ = w, r
79+
fmt.Println("Server received ping on v1.2.0 handler")
80+
return stream.FullClose()
81+
},
82+
},
83+
{
84+
Version: semver.New("1.1.0"), // Server handler for legacy 1.1.0
85+
Handler: func(ctx context.Context, p p2p.Peer, stream p2p.Stream) error {
86+
w, r := protobuf.NewWriterAndReader(stream)
87+
_, _ = w, r
88+
fmt.Println("Server received ping on v1.1.0 legacy handler")
89+
return stream.FullClose()
90+
},
91+
},
92+
{
93+
Version: semver.New("1.0.0"), // Server handler for legacy 1.0.0
94+
Handler: func(ctx context.Context, p p2p.Peer, stream p2p.Stream) error {
95+
w, r := protobuf.NewWriterAndReader(stream)
96+
_, _ = w, r
97+
fmt.Println("Server received ping on v1.0.0 legacy handler")
98+
return stream.FullClose()
99+
},
100+
},
101+
},
102+
versioned.WithMetricCounter(s.metrics.HandledStreamVersionCount),
103+
),
104+
},
105+
},
106+
}
107+
}
108+
109+
func (s *ExampleService) Ping(ctx context.Context, peer swarm.Address) error {
110+
stream, err := s.streamer.NewStream(ctx, peer, nil, exampleProtocolName, "1.1.0", exampleStreamName)
111+
if err != nil {
112+
return err
113+
}
114+
defer stream.Close()
115+
116+
pingClient := versioned.NewHandlersFunc(
117+
[]versioned.Handler{
118+
{
119+
Version: semver.New("1.2.0"), // Current version client (>= 1.2.0)
120+
Handler: func(ctx context.Context, _ p2p.Peer, stream p2p.Stream) error {
121+
w, r := protobuf.NewWriterAndReader(stream)
122+
_, _ = w, r
123+
fmt.Println("Client sent ping using v1.2.0 format")
124+
return nil
125+
},
126+
},
127+
{
128+
Version: semver.New("1.1.0"), // Legacy v1.1.0 client (1.1.0 <= v < 1.2.0)
129+
Handler: func(ctx context.Context, _ p2p.Peer, stream p2p.Stream) error {
130+
w, r := protobuf.NewWriterAndReader(stream)
131+
_, _ = w, r
132+
fmt.Println("Client sent ping using v1.1.0 legacy format")
133+
return nil
134+
},
135+
},
136+
{
137+
Version: semver.New("1.0.0"), // Legacy v1.0.0 client (1.0.0 <= v < 1.1.0)
138+
Handler: func(ctx context.Context, _ p2p.Peer, stream p2p.Stream) error {
139+
w, r := protobuf.NewWriterAndReader(stream)
140+
_, _ = w, r
141+
fmt.Println("Client sent ping using v1.0.0 legacy format")
142+
return nil
143+
},
144+
},
145+
},
146+
versioned.WithMetricCounter(s.metrics.HandledStreamVersionCount),
147+
)
148+
149+
return pingClient(ctx, p2p.Peer{Address: peer}, stream)
150+
}
151+
152+
type mockStreamer struct {
153+
p2p.Streamer
154+
supportedVersions map[string]bool
155+
}
156+
157+
func (m *mockStreamer) NewStream(_ context.Context, _ swarm.Address, _ p2p.Headers, _, version, _ string) (p2p.Stream, error) {
158+
if !m.supportedVersions[version] {
159+
return nil, fmt.Errorf("protocol version not supported: %s", version)
160+
}
161+
return mockStream{
162+
version: version,
163+
}, nil
164+
}
165+
166+
// Example_versionedProtocol demonstrates constructing a versioned P2P protocol service
167+
// with Prometheus metrics tracking and executing versioned message exchange between client and server nodes.
168+
func Example_versionedProtocol() {
169+
ctx := context.Background()
170+
171+
serverSvc := NewExampleService(nil)
172+
serverSpec := serverSvc.Protocol()
173+
serverHandler := serverSpec.StreamSpecs[0].Handler
174+
175+
// Client streamer connects to server where version 1.1.0 is negotiated:
176+
clientStreamer := &mockStreamer{
177+
supportedVersions: map[string]bool{
178+
"1.1.0": true,
179+
},
180+
}
181+
clientSvc := NewExampleService(clientStreamer)
182+
183+
// Client sends ping request (client-side dispatcher automatically selects v1.1.0 format)
184+
_ = clientSvc.Ping(ctx, swarm.ZeroAddress)
185+
186+
// Server receives incoming stream (server-side dispatcher automatically selects v1.1.0 handler)
187+
incomingStream := mockStream{
188+
version: "1.1.0",
189+
}
190+
_ = serverHandler(ctx, p2p.Peer{Address: swarm.ZeroAddress}, incomingStream)
191+
192+
// Output:
193+
// Client sent ping using v1.1.0 legacy format
194+
// Server received ping on v1.1.0 legacy handler
195+
}

pkg/p2p/versioned/versioned.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Copyright 2026 The Swarm Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
// Package versioned provides helpers for constructing versioned P2P stream handlers.
6+
package versioned
7+
8+
import (
9+
"context"
10+
"fmt"
11+
"sort"
12+
13+
"github.com/coreos/go-semver/semver"
14+
"github.com/ethersphere/bee/v2/pkg/p2p"
15+
"github.com/prometheus/client_golang/prometheus"
16+
)
17+
18+
// Handler represents a p2p.HandlerFunc associated with a minimum supported Version threshold.
19+
type Handler struct {
20+
Version *semver.Version
21+
Handler p2p.HandlerFunc
22+
}
23+
24+
// Option defines a functional option for configuring NewHandlersFunc behavior.
25+
type Option interface {
26+
apply(*config)
27+
}
28+
29+
type optionFunc func(*config)
30+
31+
func (f optionFunc) apply(c *config) {
32+
f(c)
33+
}
34+
35+
type config struct {
36+
counter *prometheus.CounterVec
37+
onMatch func(version *semver.Version)
38+
}
39+
40+
// WithMetricCounter configures a prometheus.CounterVec (labeled by "version") to be incremented when a version handler is matched.
41+
func WithMetricCounter(counter *prometheus.CounterVec) Option {
42+
return optionFunc(func(c *config) {
43+
c.counter = counter
44+
})
45+
}
46+
47+
// WithOnMatchFunc configures a custom callback function that is invoked with the matched version.
48+
func WithOnMatchFunc(fn func(version *semver.Version)) Option {
49+
return optionFunc(func(c *config) {
50+
c.onMatch = fn
51+
})
52+
}
53+
54+
// NewHandlersFunc creates a new p2p.HandlerFunc that dispatches stream execution
55+
// based on the stream version.
56+
//
57+
// Handlers are evaluated in descending order of Version. The first handler where
58+
// stream.Version >= handler.Version will be executed.
59+
func NewHandlersFunc(handlers []Handler, opts ...Option) p2p.HandlerFunc {
60+
sorted := make([]Handler, len(handlers))
61+
copy(sorted, handlers)
62+
sort.Slice(sorted, func(i, j int) bool {
63+
return sorted[j].Version.LessThan(*sorted[i].Version)
64+
})
65+
66+
cfg := &config{}
67+
for _, opt := range opts {
68+
opt.apply(cfg)
69+
}
70+
71+
return func(ctx context.Context, p p2p.Peer, stream p2p.Stream) error {
72+
v, err := stream.Version()
73+
if err != nil {
74+
return fmt.Errorf("get stream version: %w", err)
75+
}
76+
77+
for _, h := range sorted {
78+
if !v.LessThan(*h.Version) {
79+
if cfg.counter != nil {
80+
cfg.counter.WithLabelValues(h.Version.String()).Inc()
81+
}
82+
if cfg.onMatch != nil {
83+
cfg.onMatch(h.Version)
84+
}
85+
return h.Handler(ctx, p, stream)
86+
}
87+
}
88+
89+
return fmt.Errorf("no handler found for stream version: %s", v.String())
90+
}
91+
}

0 commit comments

Comments
 (0)