Skip to content

Commit 72d52b4

Browse files
committed
ff-add-ValkeyClusterDescription
1 parent 4ca3c05 commit 72d52b4

File tree

3 files changed

+304
-0
lines changed

3 files changed

+304
-0
lines changed

Sources/Valkey/Cluster/HashSlot.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@ extension HashSlot: RawRepresentable {
7070
self._raw = UInt16(rawValue)
7171
}
7272

73+
public init?(rawValue: Int64) {
74+
guard HashSlot.min.rawValue <= rawValue, rawValue <= HashSlot.max.rawValue else { return nil }
75+
self._raw = UInt16(rawValue)
76+
}
77+
7378
public var rawValue: UInt16 { self._raw }
7479
}
7580

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the swift-valkey project
4+
//
5+
// Copyright (c) 2025 the swift-valkey authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See swift-valkey/CONTRIBUTORS.txt for the list of swift-valkey authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import NIOCore
16+
17+
package struct ValkeyClusterParseError: Error {
18+
fileprivate enum Reason: Error{
19+
case clusterDescriptionTokenIsNotAnArray
20+
case shardTokenIsNotAnArray
21+
case nodesTokenIsNotAnArray
22+
case nodeTokenIsNotAnArray
23+
case slotsTokenIsNotAnArray
24+
case invalidNodeRole
25+
case invalidNodeHealth
26+
case missingRequiredValueForNode
27+
case shardIsMissingNode
28+
}
29+
30+
fileprivate var reason: Reason
31+
package var token: RESPToken
32+
}
33+
34+
package struct ValkeyClusterDescription: Hashable, Sendable {
35+
/// Details for a node within a cluster shard
36+
package struct Node: Hashable, Sendable {
37+
/// Replication role of a given shard (master or replica)
38+
package enum Role: String {
39+
case master
40+
case replica
41+
}
42+
43+
/// Node's health status
44+
package enum Health: String {
45+
case online
46+
case failed
47+
case loading
48+
}
49+
50+
package var id: String
51+
package var port: Int?
52+
package var tlsPort: Int?
53+
package var ip: String
54+
package var hostname: String?
55+
package var endpoint: String
56+
package var role: Role
57+
package var replicationOffset: Int
58+
package var health: Health
59+
}
60+
61+
package struct Shard: Hashable, Sendable {
62+
package var slotRanges: HashSlots
63+
package var nodes: [Node]
64+
65+
package var master: Node? {
66+
self.nodes.first
67+
}
68+
69+
package var replicas: ArraySlice<Node> {
70+
self.nodes.dropFirst(1)
71+
}
72+
}
73+
74+
package var shards: [Shard]
75+
76+
package init(respToken: RESPToken) throws(ValkeyClusterParseError) {
77+
do {
78+
self = try Self.makeClusterDescription(respToken: respToken)
79+
} catch {
80+
throw ValkeyClusterParseError(reason: error, token: respToken)
81+
}
82+
}
83+
84+
package init(_ shards: [ValkeyClusterDescription.Shard]) {
85+
self.shards = shards
86+
}
87+
}
88+
89+
extension ValkeyClusterDescription {
90+
fileprivate static func makeClusterDescription(respToken: RESPToken) throws(ValkeyClusterParseError.Reason) -> ValkeyClusterDescription {
91+
guard case .array(let shardsToken) = respToken.value else {
92+
throw .clusterDescriptionTokenIsNotAnArray
93+
}
94+
95+
let shards = try shardsToken.map { shardToken throws(ValkeyClusterParseError.Reason) in
96+
97+
guard case .array(let keysAndValues) = shardToken.value else {
98+
throw .shardTokenIsNotAnArray
99+
}
100+
101+
var slotRanges: HashSlots = []
102+
var nodes: [ValkeyClusterDescription.Node] = []
103+
104+
var keysAndValuesIterator = keysAndValues.makeIterator()
105+
while let keyToken = keysAndValuesIterator.next(), let key = String(keyToken) {
106+
switch key {
107+
case "slots":
108+
slotRanges = try HashSlots(&keysAndValuesIterator)
109+
110+
case "nodes":
111+
nodes = try [ValkeyClusterDescription.Node](&keysAndValuesIterator)
112+
113+
default:
114+
continue
115+
}
116+
}
117+
118+
// nodes must not be empty
119+
if nodes.isEmpty {
120+
throw .shardIsMissingNode
121+
}
122+
123+
return ValkeyClusterDescription.Shard(slotRanges: slotRanges, nodes: nodes)
124+
}
125+
126+
return ValkeyClusterDescription(shards)
127+
}
128+
}
129+
130+
extension String {
131+
fileprivate init?(_ respToken: RESPToken) {
132+
switch respToken.value {
133+
case .bulkString(var byteBuffer),
134+
.simpleString(var byteBuffer),
135+
.blobError(var byteBuffer),
136+
.simpleError(var byteBuffer),
137+
.verbatimString(var byteBuffer):
138+
self = byteBuffer.readString(length: byteBuffer.readableBytes)!
139+
140+
case .double(let value):
141+
self = "\(value)"
142+
143+
case .number(let value):
144+
self = "\(value)"
145+
146+
case .boolean(let value):
147+
self = "\(value)"
148+
149+
case .array, .attribute, .bigNumber, .push, .set, .null, .map:
150+
return nil
151+
}
152+
}
153+
}
154+
155+
extension Int64 {
156+
fileprivate init?(_ respToken: RESPToken) {
157+
switch respToken.value {
158+
case .number(let value):
159+
self = value
160+
161+
case .bulkString,
162+
.simpleString,
163+
.blobError,
164+
.simpleError,
165+
.verbatimString,
166+
.double,
167+
.boolean,
168+
.array,
169+
.attribute,
170+
.bigNumber,
171+
.push,
172+
.set,
173+
.null,
174+
.map:
175+
return nil
176+
}
177+
}
178+
}
179+
180+
extension HashSlots {
181+
fileprivate init(_ iterator: inout RESPToken.Array.Iterator) throws(ValkeyClusterParseError.Reason) {
182+
guard case .array(let array) = iterator.next()?.value else {
183+
throw .slotsTokenIsNotAnArray
184+
}
185+
186+
var slotRanges = [ClosedRange<HashSlot>]()
187+
slotRanges.reserveCapacity(array.count / 2)
188+
189+
var slotsIterator = array.makeIterator()
190+
while case .number(let rangeStart) = slotsIterator.next()?.value,
191+
case .number(let rangeEnd) = slotsIterator.next()?.value,
192+
let start = HashSlot(rawValue: rangeStart),
193+
let end = HashSlot(rawValue: rangeEnd),
194+
start <= end
195+
{
196+
slotRanges.append(ClosedRange<HashSlot>(uncheckedBounds: (start, end)))
197+
}
198+
199+
self = slotRanges
200+
}
201+
}
202+
203+
extension [ValkeyClusterDescription.Node] {
204+
fileprivate init(_ iterator: inout RESPToken.Array.Iterator) throws(ValkeyClusterParseError.Reason) {
205+
guard case .array(let array) = iterator.next()?.value else {
206+
throw .nodesTokenIsNotAnArray
207+
}
208+
209+
self = try array.map { token throws(ValkeyClusterParseError.Reason) in
210+
try ValkeyClusterDescription.Node(token)
211+
}
212+
}
213+
}
214+
215+
extension ValkeyClusterDescription.Node {
216+
fileprivate init(_ token: RESPToken) throws(ValkeyClusterParseError.Reason) {
217+
guard case .array(let array) = token.value else {
218+
throw .nodeTokenIsNotAnArray
219+
}
220+
221+
var id: String?
222+
var port: Int64?
223+
var tlsPort: Int64?
224+
var ip: String?
225+
var hostname: String?
226+
var endpoint: String?
227+
var role: ValkeyClusterDescription.Node.Role?
228+
var replicationOffset: Int64?
229+
var health: ValkeyClusterDescription.Node.Health?
230+
231+
var nodeIterator = array.makeIterator()
232+
while let nodeKey = nodeIterator.next(), let key = String(nodeKey), let nodeVal = nodeIterator.next() {
233+
switch key {
234+
case "id":
235+
id = String(nodeVal)
236+
case "port":
237+
port = Int64(nodeVal)
238+
case "tls-port":
239+
tlsPort = Int64(nodeVal)
240+
case "ip":
241+
ip = String(nodeVal)
242+
case "hostname":
243+
hostname = String(nodeVal)
244+
case "endpoint":
245+
endpoint = String(nodeVal)
246+
case "role":
247+
guard let roleString = String(nodeVal), let roleValue = ValkeyClusterDescription.Node.Role(rawValue: roleString) else {
248+
throw .invalidNodeRole
249+
}
250+
role = roleValue
251+
252+
case "replication-offset":
253+
replicationOffset = Int64(nodeVal)
254+
case "health":
255+
guard let healthString = String(nodeVal), let healthValue = ValkeyClusterDescription.Node.Health(rawValue: healthString) else {
256+
throw .invalidNodeHealth
257+
}
258+
health = healthValue
259+
260+
default:
261+
// we ignore unexpected keys to be forward compliant
262+
continue
263+
}
264+
}
265+
guard let id = id, let ip = ip, let endpoint = endpoint, let role = role,
266+
let replicationOffset = replicationOffset, let health = health
267+
else {
268+
throw .missingRequiredValueForNode
269+
}
270+
271+
self = ValkeyClusterDescription.Node(
272+
id: id,
273+
port: port.flatMap { Int($0) },
274+
tlsPort: tlsPort.flatMap { Int($0) },
275+
ip: ip,
276+
hostname: hostname,
277+
endpoint: endpoint,
278+
role: role,
279+
replicationOffset: Int(replicationOffset),
280+
health: health
281+
)
282+
}
283+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the swift-valkey project
4+
//
5+
// Copyright (c) 2025 the swift-valkey authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See swift-valkey/CONTRIBUTORS.txt for the list of swift-valkey authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import Testing
16+
import Valkey

0 commit comments

Comments
 (0)