Skip to content

Perfect-Redis 4.0.0

Latest

Choose a tag to compare

@taplin taplin released this 14 Oct 00:09
5d861e8

The first release since 3.4.1 (2019). It's a rewrite for Swift 6. RedisClient is now an actor with an async throws API, built on swift-server/RediStack rather than its own RESP code over Perfect-Net. This release also makes pub/sub deliver messages, keeps other tasks' commands out of multi transactions, adds command timeouts and cancellation, and stops logging by default. Bad arguments and hostile server replies now throw instead of crashing. It's a major version: the 3.x callback and blocking APIs are gone, so read "Breaking and behaviour changes" before upgrading.

Requirements

  • Swift tools 6.2 (// swift-tools-version: 6.2), Swift 6 language mode. 3.4.1 declared tools 4.0.
  • Platforms: macOS 12+ and iOS 17+ (both newly declared; 3.4.1 declared none), and Linux.
  • A Redis server speaking RESP2. The live tests run against Redis 7.4.
  • Dependencies: RediStack from: "1.6.0", plus SwiftNIO from: "2.65.0" and swift-log from: "1.5.3", both of which RediStack already required. Perfect-Net is no longer used. Nothing needs installing on any platform.
.package(url: "https://github.com/PerfectlySoft/Perfect-Redis.git", from: "4.0.0")

CI now runs on GitHub Actions for every push and pull request. On Linux (Swift 6.2 and 6.4, debug and release) it runs every test, including the live ones, against a Redis 7 service, and fails if that server can't be reached. macOS (Xcode 26.0.1 and 26.6, debug and release) runs the tests that don't need a server. The iOS Simulator job builds at the iOS 17 deployment target. Every build treats warnings as errors.

Highlights

  • Swift 6 async API (#22). RedisClient is an actor. Its methods keep the 3.x command names (set(key:value:expires:), listAppend, hashGetAll, …) and return RedisResponse, but each one is async throws. RedisClientIdentifier, RedisResponse, RedisValue and the RedisHash/RedisList/RedisSet wrappers are Sendable.
  • Built on RediStack (#22). Connection handling and RESP parsing come from RediStack and SwiftNIO. Commands from concurrent tasks are pipelined on one connection, and every argument is sent as a length-prefixed bulk string. A test sends keys, values, hash fields and a command name containing CR, LF, $ and *, and none of them injects a command. All 256 byte values round-trip.
  • Pub/sub works. Messages arrive on client.messages, an AsyncStream<RedisClient.PublishedMessage> holding the channel and the payload. Channel and pattern subscriptions both deliver, and the stream ends whenever the connection closes.
  • Transactions are isolated. While multi(body:) runs, other tasks' commands on the same client wait instead of being queued into the transaction.
  • Timeouts and cancellation. The new RedisClientIdentifier(…, commandTimeout:) bounds connecting (including AUTH, but not the host name lookup), each reply, subscription changes, and the waits for another task's transaction, for earlier subscription changes and for commands in flight, as one budget per call. Cancelling the waiting task also returns promptly. Once a command has been sent, either one closes the connection; a wait that hasn't sent anything just throws.
  • Hostile replies don't crash the process. A guard in front of RediStack's parser checks reply framing and closes the connection instead of passing on anything that would overflow RediStack's arithmetic, nest deeper than 64 levels, make it reserve memory for more than 2²⁴ outstanding array elements, declare a string over 512 MB, or end a line without CRLF.
  • Quiet by default. RediStack logs nothing unless you pass a logger.
  • Swift Testing. 111 tests. 52 of them need no server: they use a scripted fake server or feed bytes to the reply guard directly. The 58 live tests run when REDIS_TESTS=1, and REDIS_HOST and REDIS_PORT configure them. One more test fails the run under REDIS_REQUIRE_LIVE=1 when the server can't be reached.

Breaking and behaviour changes

  • Toolchain and platforms. Swift tools 6.2 is required, with macOS 12 or iOS 17 at minimum. Upgrade: use Swift 6.2 or newer and raise your deployment targets.
  • Connecting (#22). RedisClient.getClient(withIdentifier:) (callback or throwing form), RedisClient.releaseClient(_:), RedisClientIdentifier.client() and RedisClient.init(net:) are gone, along with RedisClientIdentifier's netGenerator. Upgrade: let client = try await RedisClient.connect(withIdentifier: id), and try await client.close() when done.
  • Every command is async throws (#22). The completion-handler methods and the blocking throws versions from RedisClientSync.swift are removed. Upgrade: client.ping { r in … } or try client.ping() becomes try await client.ping().
  • Server error replies are thrown (#22). 3.x handed them back as .error(type:msg:). 4.0 throws RediStack's RedisError, unwrapped, whose message starts with (Redis) . A closed connection throws RediStack's RedisClientError. .error now appears only inside arrays such as EXEC results. Upgrade: move .error checks into catch.
  • Raw commands (#22). sendCommand(name:parameters:callback:) and sendRawCommand(bytes:) are removed. Upgrade: use sendCommandAsRESP(name:parameters:), which sends the name and every parameter as bulk strings.
  • String conversions (#22). RedisResponse.toString() and RedisValue.toString() are removed, and so is RedisValue.commandString. Upgrade: use the string property.
  • Collection-style wrappers (#22). RedisHash, RedisList and RedisSet lost their 3.x collection API: init(_:name:), subscripts, Sequence iteration, and the exists, count, keys and values properties. client.hash(named:), client.list(named:) and client.set(named:) are gone too. Upgrade: use RedisHash(client:key:) (and the same for lists and sets), whose async methods are named after the commands, for example get(field:), getAll(), append(_:) and members().
  • sendCommandAsRESP refuses some commands. It throws CommandError without sending anything for the SUBSCRIBE/UNSUBSCRIBE family (including the P and S variants), MONITOR, SYNC, PSYNC, CLIENT REPLY OFF or SKIP, HELLO with any protocol version other than 2, and RESET. HELLO, HELLO 2 and CLIENT REPLY ON still go through. Upgrade: use subscribe for pub/sub, and close() plus a new connection instead of RESET. The other commands have no supported replacement.
  • clientReply(type:) is removed (#22). Upgrade: none; replies stay on.
  • Pub/sub. readPublished(timeoutSeconds:) is removed, and subscribe(channels:), subscribe(patterns:), unsubscribe(channels:) and unsubscribe(patterns:) now return Void, not a RedisResponse. An empty subscribe list throws CommandError. Subscription changes run one at a time, in order. subscribe waits until every command the client has already sent has its reply. From then until the last subscription ends, the client throws CommandError for every other command, PING included. unsubscribe ignores names the client isn't subscribed to, and an empty list means every subscription of that kind. subscribe inside multi(body:) is refused. A subscription the server refuses (an ACL NOPERM) waits until commandTimeout, because RediStack drops the error reply. Upgrade: iterate client.messages on a dedicated client, use a second client to publish or run other commands, and set commandTimeout if your server restricts channels.
  • Timeouts. netTimeout is gone. Upgrade: pass commandTimeout: (seconds) to RedisClientIdentifier(withHost:port:password:commandTimeout:logger:). The default is no timeout. It is one budget per call, covering every wait and the reply. If it expires before anything is sent, the call throws CommandError and the connection stays open; if it expires while waiting for the reply (or during connect), the call throws CommandError and the connection closes. A non-positive or non-finite value makes connect throw. multi(body:) isn't one budget: waiting for another task's transaction, MULTI, each command in the body and EXEC each get their own.
  • Cancellation closes the connection. Cancelling a task while its command waits for a reply closes the client's connection and throws CancellationError. Other commands in flight on that client fail too. A task that is already cancelled, or cancelled while waiting its turn, throws without sending anything. A task cancelled inside multi(body:) closes the connection, which discards the server-side MULTI. Upgrade: give work you may cancel its own client.
  • close() waits at most a second. Outstanding replies get up to a second to arrive; then the connection closes and those commands throw. Releasing a client without close() now closes its connection immediately instead of leaving it open. Upgrade: nothing for most code; let outstanding commands finish before close() if you need their replies.
  • multi(body:) makes other tasks wait. Until EXEC or DISCARD, commands sent from other tasks on the same client are held back. Commands from the body and its child tasks go through, and transactions on different clients can nest. A multi on the same client nested inside the body throws CommandError, and a failed DISCARD no longer replaces the body's error. Upgrade: nothing for most code. Inside the body, don't wait on a task that uses the same client but was started before the transaction or detached from it. That task's commands wait for the transaction to end, so the two deadlock. Child tasks, and Task {} created inside the body, inherit the transaction and aren't affected. Two tasks that take transactions on two clients in opposite orders also deadlock, unless commandTimeout is set.
  • Durations that don't fit throw. set(expires:), expire(seconds:), expireAt(seconds:) and clientPause(timeoutSeconds:) throw CommandError for NaN, infinity, or more than about 9.2e15 seconds, instead of trapping. Upgrade: handle the error; the methods were already throws.
  • Only [UInt8] arrays are RedisValueRepresentable. 3.x applied the conformance to any Array whose Element: ROctal and force-cast it to [UInt8]. ROctal remains but no longer adds the conformance. Upgrade: nothing, unless you conformed your own type to ROctal; convert its arrays to [UInt8].
  • No logging by default. RediStack's logs (every command and its arguments at debug, every failed command at error) are discarded unless you pass logger: to RedisClientIdentifier. Upgrade: pass a Logger if you relied on RediStack's log lines. At debug they include the AUTH password and every value.

Fixes

  • #22: The Swift 6 rewrite on RediStack, which replaced the hand-written RESP reader and Perfect-Net socket code.
  • #23: A key holding an empty string read back as missing. RediStack decodes $0 as .bulkString(nil), and the rewrite mapped that to isNil == true, the same as a missing key. Empty values are now .bulkString([]) and read back as "". The bug was in the rewrite, not in 3.4.1.
  • #23: An error nested in an array (from EXEC) came out as .error(type: "(Redis)", msg: "WRONGTYPE …"). RediStack's prefix is now stripped, so type is the Redis error code.
  • #23: Several hostile replies broke RediStack's parser, all reproduced with a scripted fake server. A 22-byte reply, $9223372036854775807\r\n, crashed the process with an arithmetic overflow. About 100,000 nested *1 headers crashed it with a stack overflow. A count like *4294967295 made RediStack reserve about 100 GB up front, which aborted the process on Linux and hung on macOS. A length line ending in a bare LF ($55\n) was read as different lengths by the guard and by RediStack, which let the first two crashes through the guard's first version. Now a guard in front of the parser fails the connection with CommandError for any of these:
    • a bulk length outside -1…512 MB;
    • an array count outside -1…2²⁴;
    • more than 2²⁴ array elements outstanding across nested arrays;
    • nesting deeper than 64;
    • a header or line not ending in CRLF;
    • a malformed length header;
    • an unknown type byte.
  • #23: Pub/sub never delivered. subscribe sent SUBSCRIBE as an ordinary command, so the second channel's confirmation and every published message arrived with no command waiting for them, and RediStack dropped them (reproduced against Redis 7). Subscriptions now go through RediStack's subscription API.
  • #23: Subscribing while another task's command was still in flight sent that command's reply through RediStack's pub/sub handler. Against a real Redis 7.4, with ordinary data, this crashed the process: an EVAL returning {'subscribe','x','y'} hit a force unwrap in RediStack's handler. A reply shaped like {'message','c','forged'} was delivered as a forged published message, and its caller hung. subscribe now waits for commands in flight, and a subscribed client refuses other commands.
  • #23: Commands sent by other tasks during multi(body:) were queued into the transaction. Against Redis 7, a GET from another task returned QUEUED. Two concurrent multi calls failed with "MULTI calls can not be nested". A first fix used one task-local marker for all clients, so a.multi { b.multi { a.set … } } hung; the marker is now per client. A later change (refusing commands from cancelled tasks) briefly meant a cancelled body couldn't send DISCARD or EXEC, leaving MULTI open so other tasks' commands were queued and a later EXEC committed the cancelled writes; multi now closes the connection in that case.
  • #23: Through sendCommandAsRESP, HELLO 3 switched the connection to RESP3, which can't be parsed, so the connection closed. CLIENT REPLY SKIP shifted every later reply onto the wrong command, and the last command hung. RESET ended subscriptions behind RediStack's back, so a later unsubscribe hung. All three were reproduced and are now refused.
  • #23: A command to a server that stopped answering waited forever, and cancelling the task didn't interrupt it. The same was true of subscribe/unsubscribe, of AUTH during connect, of a command waiting for another task's transaction, and of subscribe waiting behind another task's BLPOP 0. commandTimeout and cancellation now cover all of them. An already-cancelled task no longer sends its command and then closes the connection on everyone else.
  • #23: Several ordinary subscription sequences hung forever and left the client refusing every command, because RediStack 1.6.3 drops replies it can't match: unsubscribe(channels: []) with only patterns subscribed (and the reverse), unsubscribing an unknown or duplicated channel, or the same channel twice; a subscription change whose connection dropped; and an unsubscribe-all racing a subscribe. Concurrent changes could also switch subscription mode off under a waiting subscribe, letting a PING out mid-SUBSCRIBE. The client now tracks its own subscriptions, sends only names RediStack tracks, and runs subscription changes one at a time. Every wait for a reply also ends when the connection closes, through a single close callback per connection; each wait deregisters when it finishes, so memory doesn't grow per command. On a closed connection, subscribe and unsubscribe both throw connectionClosed.
  • #23: close() waited forever while any reply was outstanding (another task's BLPOP 0, or a silent server). The messages stream never finished when the connection dropped. A client released without close() left its socket open. After a failed AUTH, connect threw but left the TCP connection open.
  • #23: RediStack logged through the global swift-log setup. At debug level that included the AUTH password and every command's arguments and replies. At the default level every failed command printed an error line to stderr, echoing server error text that can contain user data. It now gets a no-op logger unless one is passed.
  • #23: set(expires:), expire, expireAt and clientPause trapped on non-finite or out-of-range seconds. Perfect-Session passes its idle setting straight into expires:.
  • #23: The [UInt8] conformance's force cast, which trapped for any other ROctal array.
  • #23: Swift 6.2 (Linux and Xcode 26.3) ran no tests at all, because an empty Tests/LinuxMain.swift stub was treated as the test entry point. swift test exited 0 without running anything. The stub is gone.
  • #23: The two live suites ran in parallel against one server, with both running FLUSHALL and reusing mykey, so four or five tests failed with WRONGTYPE on every run against Redis 7 (also on the pre-release main branch). They now run serially. They also read REDIS_HOST/REDIS_PORT instead of a hard-coded 127.0.0.1:6379, report as skipped rather than passed when disabled, and fail under REDIS_REQUIRE_LIVE=1 if the server can't be reached.
  • #23: The library declared no dependency on the NIO modules it imports, and the build had Sendable warnings, which -warnings-as-errors turns into failures. Both are fixed; RediStack is imported @preconcurrency, with a comment explaining why that is sound.
  • #23: Cleanup. The committed Package.resolved, the stale 3.x jazzy docs/ and .jazzy.yaml, and the RedisClientSync.swift stub are gone. The README now covers 4.0.0, and the Chinese README says it describes 3.x.

Known limitations

  • iOS testing. iOS 17 is declared and the library builds for iOS, but the CI iOS Simulator job doesn't pass: one timing-sensitive test (oneDeadlineCoversWaitingAndTheReply, which expects a call to finish within 1.4 s) runs too slowly on GitHub's simulator runners (about 3.1 s). The other 110 tests pass there. iOS use is expected to be rare; the test's bound will be relaxed in a later release.
  • RESP2 only. No RESP3, so no client-side caching push messages.
  • No TLS. RediStack 1.6 has no TLS option. Use a tunnel or proxy to reach a TLS-only server.
  • No ACL username. AUTH sends the password alone, which authenticates as the default user.
  • One connection per client, with no reconnection or pooling. A blocking command such as BLPOP holds up every later command on the same client, and subscribe waits for it.
  • Replies of more than 2²⁴ elements are refused. The reply guard's cap, which stops a hostile server making RediStack reserve unbounded memory, also rejects legitimate replies with more than 16,777,216 array elements outstanding (an LRANGE, KEYS, SMEMBERS or HGETALL of a bigger collection): they fail with CommandError and close the connection. Page through large collections instead.
  • Host name lookup isn't bounded by commandTimeout: connect resolves the host with a blocking system call first.
  • Refused subscriptions wait for the timeout. RediStack drops a SUBSCRIBE error reply (such as an ACL NOPERM), so without commandTimeout the call waits until the connection closes. Meanwhile the client refuses every command, and later subscription changes queue behind it.
  • messages is unbounded and shared. One stream per client carries every subscription's messages, and a consumer that falls behind makes it buffer without limit.
  • Thrown errors are RediStack's types (RedisError, RedisClientError, RESPTranslator.ParsingError), unwrapped, alongside RedisClient.CommandError.
  • The reply guard checks framing only. Within the limits above, RediStack still parses contents. Its parser re-reads a partial reply from the start on every read, so a very large reply costs time quadratic in the number of reads.
  • RediStack is deprecated upstream. Its maintainers point new users to valkey-swift. This release deliberately stays on RediStack 1.6, which builds and passes on Swift 6.2 and 6.4.

Upgrade notes

  1. Point your dependency at from: "4.0.0", build with Swift 6.2 or newer, and raise your deployment targets to macOS 12 / iOS 17.
  2. Replace getClient/releaseClient with try await RedisClient.connect(withIdentifier:) and try await client.close().
  3. Make each call try await, and catch server errors (RedisError) instead of matching .error replies.
  4. Rewrite raw sendCommand and sendRawCommand calls to use sendCommandAsRESP, and toString() calls to use .string.
  5. Replace collection-style RedisHash/RedisList/RedisSet use (subscripts, iteration, client.hash(named:)) with RedisHash(client:key:) and its async methods.
  6. For pub/sub, subscribe on a dedicated client and read client.messages. subscribe no longer returns a value, and a subscribed client refuses other commands.
  7. If you used netTimeout, pass commandTimeout: to RedisClientIdentifier. Expect the connection to close on a timeout or cancellation, and reconnect after one.
  8. If you want RediStack's logs, pass logger:, and keep it above debug level in production.
  9. Code that tests isNil to detect a missing key now sees an empty string as present. That's the correct behaviour, and it matches 3.4.1.

Legacy

The pre-Swift-6 code is preserved on the legacy branch, which points at the same commit as the 3.4.1 tag. Stay on 3.4.1 if you can't move to Swift 6.2 yet.