Skip to content

Commit 7392232

Browse files
committed
Add Blocking Sorted Set Pop Commands
Motivation: To be a comprehensive library, all commands should be implemented, even if they are highly discouraged. Sorted Set's collection of commands were missing `bzpopmin` and `bzpopmax`. Modifications: `bzpopmin` and `bzpopmax` are supported with defaults and overloads for an easier API. `RedisClient.channel` is now `internal` to have access during testing for bypassing normal guards for closing connections. Result: Users now have access to `bzpopmin` and `bzpopmax` commands.
1 parent d0da3e7 commit 7392232

File tree

3 files changed

+186
-1
lines changed

3 files changed

+186
-1
lines changed

Sources/NIORedis/Commands/SortedSetCommands.swift

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,149 @@ extension RedisClient {
292292
}
293293
}
294294

295+
// MARK: Blocking Pop
296+
297+
extension RedisClient {
298+
/// Removes the element from a sorted set with the lowest score, blocking until an element is
299+
/// available.
300+
///
301+
/// - Important:
302+
/// This will block the connection from completing further commands until an element
303+
/// is available to pop from the set.
304+
///
305+
/// It is **highly** recommended to set a reasonable `timeout`
306+
/// or to use the non-blocking `zpopmin` method where possible.
307+
///
308+
/// See [https://redis.io/commands/bzpopmin](https://redis.io/commands/bzpopmin)
309+
/// - Parameters:
310+
/// - key: The key identifying the sorted set in Redis.
311+
/// - timeout: The time (in seconds) to wait. `0` means indefinitely.
312+
/// - Returns:
313+
/// The element and its associated score that was popped from the sorted set,
314+
/// or `nil` if the timeout was reached.
315+
@inlinable
316+
public func bzpopmin(
317+
from key: String,
318+
timeout: Int = 0
319+
) -> EventLoopFuture<(Double, RESPValue)?> {
320+
return bzpopmin(from: [key], timeout: timeout)
321+
.map {
322+
guard let response = $0 else { return nil }
323+
return (response.1, response.2)
324+
}
325+
}
326+
327+
/// Removes the element from a sorted set with the lowest score, blocking until an element is
328+
/// available.
329+
///
330+
/// - Important:
331+
/// This will block the connection from completing further commands until an element
332+
/// is available to pop from the group of sets.
333+
///
334+
/// It is **highly** recommended to set a reasonable `timeout`
335+
/// or to use the non-blocking `zpopmin` method where possible.
336+
///
337+
/// See [https://redis.io/commands/bzpopmin](https://redis.io/commands/bzpopmin)
338+
/// - Parameters:
339+
/// - keys: A list of sorted set keys in Redis.
340+
/// - timeout: The time (in seconds) to wait. `0` means indefinitely.
341+
/// - Returns:
342+
/// If timeout was reached, `nil`.
343+
///
344+
/// Otherwise, the key of the sorted set the element was removed from, the element itself,
345+
/// and its associated score is returned.
346+
@inlinable
347+
public func bzpopmin(
348+
from keys: [String],
349+
timeout: Int = 0
350+
) -> EventLoopFuture<(String, Double, RESPValue)?> {
351+
return self._bzpop(command: "BZPOPMIN", keys, timeout)
352+
}
353+
354+
/// Removes the element from a sorted set with the highest score, blocking until an element is
355+
/// available.
356+
///
357+
/// - Important:
358+
/// This will block the connection from completing further commands until an element
359+
/// is available to pop from the set.
360+
///
361+
/// It is **highly** recommended to set a reasonable `timeout`
362+
/// or to use the non-blocking `zpopmax` method where possible.
363+
///
364+
/// See [https://redis.io/commands/bzpopmax](https://redis.io/commands/bzpopmax)
365+
/// - Parameters:
366+
/// - key: The key identifying the sorted set in Redis.
367+
/// - timeout: The time (in seconds) to wait. `0` means indefinitely.
368+
/// - Returns:
369+
/// The element and its associated score that was popped from the sorted set,
370+
/// or `nil` if the timeout was reached.
371+
@inlinable
372+
public func bzpopmax(
373+
from key: String,
374+
timeout: Int = 0
375+
) -> EventLoopFuture<(Double, RESPValue)?> {
376+
return self.bzpopmax(from: [key], timeout: timeout)
377+
.map {
378+
guard let response = $0 else { return nil }
379+
return (response.1, response.2)
380+
}
381+
}
382+
383+
/// Removes the element from a sorted set with the highest score, blocking until an element is
384+
/// available.
385+
///
386+
/// - Important:
387+
/// This will block the connection from completing further commands until an element
388+
/// is available to pop from the group of sets.
389+
///
390+
/// It is **highly** recommended to set a reasonable `timeout`
391+
/// or to use the non-blocking `zpopmax` method where possible.
392+
///
393+
/// See [https://redis.io/commands/bzpopmax](https://redis.io/commands/bzpopmax)
394+
/// - Parameters:
395+
/// - keys: A list of sorted set keys in Redis.
396+
/// - timeout: The time (in seconds) to wait. `0` means indefinitely.
397+
/// - Returns:
398+
/// If timeout was reached, `nil`.
399+
///
400+
/// Otherwise, the key of the sorted set the element was removed from, the element itself,
401+
/// and its associated score is returned.
402+
@inlinable
403+
public func bzpopmax(
404+
from keys: [String],
405+
timeout: Int = 0
406+
) -> EventLoopFuture<(String, Double, RESPValue)?> {
407+
return self._bzpop(command: "BZPOPMAX", keys, timeout)
408+
}
409+
410+
@usableFromInline
411+
func _bzpop(
412+
command: String,
413+
_ keys: [String],
414+
_ timeout: Int
415+
) -> EventLoopFuture<(String, Double, RESPValue)?> {
416+
let args = keys as [RESPValueConvertible] + [timeout]
417+
return send(command: command, with: args)
418+
// per the Redis docs,
419+
// we will receive either a nil response,
420+
// or an array with 3 elements in the form [Set Key, Element Score, Element Value]
421+
.flatMapThrowing {
422+
guard !$0.isNull else { return nil }
423+
guard let response = [RESPValue]($0) else {
424+
throw NIORedisError.responseConversion(to: [RESPValue].self)
425+
}
426+
assert(response.count == 3, "Unexpected response size returned!")
427+
guard
428+
let key = response[0].string,
429+
let score = Double(response[1])
430+
else {
431+
throw NIORedisError.assertionFailure(message: "Unexpected structure in response: \(response)")
432+
}
433+
return (key, score, response[2])
434+
}
435+
}
436+
}
437+
295438
// MARK: Increment
296439

297440
extension RedisClient {

Sources/NIORedis/RedisClient.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ public final class RedisConnection: RedisClient {
7676
}
7777
}
7878

79-
private let channel: Channel
79+
let channel: Channel
8080
private var logger: Logger
8181

8282
private let autoflush = Atomic<Bool>(value: true)

Tests/NIORedisTests/Commands/SortedSetCommandsTests.swift

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,26 @@ final class SortedSetCommandsTests: XCTestCase {
155155
XCTAssertEqual(results[1].1, 10)
156156
}
157157

158+
func test_bzpopmin() throws {
159+
let nilMin = try connection.bzpopmin(from: #function, timeout: 1).wait()
160+
XCTAssertNil(nilMin)
161+
162+
let min1 = try connection.bzpopmin(from: key).wait()
163+
XCTAssertEqual(min1?.0, 1)
164+
let min2 = try connection.bzpopmin(from: [#function, key]).wait()
165+
XCTAssertEqual(min2?.0, key)
166+
XCTAssertEqual(min2?.1, 2)
167+
168+
let blockingConnection = try Redis.makeConnection().wait()
169+
let expectation = XCTestExpectation(description: "bzpopmin should never return")
170+
_ = blockingConnection.bzpopmin(from: #function)
171+
.always { _ in expectation.fulfill() }
172+
173+
let result = XCTWaiter.wait(for: [expectation], timeout: 1)
174+
XCTAssertEqual(result, .timedOut)
175+
try blockingConnection.channel.close().wait()
176+
}
177+
158178
func test_zpopmax() throws {
159179
let min = try connection.zpopmax(from: key).wait()
160180
XCTAssertEqual(min?.1, 10)
@@ -167,6 +187,26 @@ final class SortedSetCommandsTests: XCTestCase {
167187
XCTAssertEqual(results[1].1, 1)
168188
}
169189

190+
func test_bzpopmax() throws {
191+
let nilMax = try connection.bzpopmax(from: #function, timeout: 1).wait()
192+
XCTAssertNil(nilMax)
193+
194+
let max1 = try connection.bzpopmax(from: key).wait()
195+
XCTAssertEqual(max1?.0, 10)
196+
let max2 = try connection.bzpopmax(from: [#function, key]).wait()
197+
XCTAssertEqual(max2?.0, key)
198+
XCTAssertEqual(max2?.1, 9)
199+
200+
let blockingConnection = try Redis.makeConnection().wait()
201+
let expectation = XCTestExpectation(description: "bzpopmax should never return")
202+
_ = blockingConnection.bzpopmax(from: #function)
203+
.always { _ in expectation.fulfill() }
204+
205+
let result = XCTWaiter.wait(for: [expectation], timeout: 1)
206+
XCTAssertEqual(result, .timedOut)
207+
try blockingConnection.channel.close().wait()
208+
}
209+
170210
func test_zincrby() throws {
171211
var score = try connection.zincrby(3_00_1398.328923, element: 1, in: key).wait()
172212
XCTAssertEqual(score, 3_001_399.328923)
@@ -352,7 +392,9 @@ final class SortedSetCommandsTests: XCTestCase {
352392
("test_zcount", test_zcount),
353393
("test_zlexcount", test_zlexcount),
354394
("test_zpopmin", test_zpopmin),
395+
("test_bzpopmin", test_bzpopmin),
355396
("test_zpopmax", test_zpopmax),
397+
("test_bzpopmax", test_bzpopmax),
356398
("test_zincrby", test_zincrby),
357399
("test_zunionstore", test_zunionstore),
358400
("test_zinterstore", test_zinterstore),

0 commit comments

Comments
 (0)