Repository navigation
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 SwiftNIOfrom: "2.65.0"and swift-logfrom: "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).
RedisClientis anactor. Its methods keep the 3.x command names (set(key:value:expires:),listAppend,hashGetAll, …) and returnRedisResponse, but each one isasync throws.RedisClientIdentifier,RedisResponse,RedisValueand theRedisHash/RedisList/RedisSetwrappers areSendable. - 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, anAsyncStream<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 (includingAUTH, 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, andREDIS_HOSTandREDIS_PORTconfigure them. One more test fails the run underREDIS_REQUIRE_LIVE=1when 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()andRedisClient.init(net:)are gone, along withRedisClientIdentifier'snetGenerator. Upgrade:let client = try await RedisClient.connect(withIdentifier: id), andtry await client.close()when done. - Every command is
async throws(#22). The completion-handler methods and the blockingthrowsversions fromRedisClientSync.swiftare removed. Upgrade:client.ping { r in … }ortry client.ping()becomestry await client.ping(). - Server error replies are thrown (#22). 3.x handed them back as
.error(type:msg:). 4.0 throws RediStack'sRedisError, unwrapped, whosemessagestarts with(Redis). A closed connection throws RediStack'sRedisClientError..errornow appears only inside arrays such asEXECresults. Upgrade: move.errorchecks intocatch. - Raw commands (#22).
sendCommand(name:parameters:callback:)andsendRawCommand(bytes:)are removed. Upgrade: usesendCommandAsRESP(name:parameters:), which sends the name and every parameter as bulk strings. - String conversions (#22).
RedisResponse.toString()andRedisValue.toString()are removed, and so isRedisValue.commandString. Upgrade: use thestringproperty. - Collection-style wrappers (#22).
RedisHash,RedisListandRedisSetlost their 3.x collection API:init(_:name:), subscripts,Sequenceiteration, and theexists,count,keysandvaluesproperties.client.hash(named:),client.list(named:)andclient.set(named:)are gone too. Upgrade: useRedisHash(client:key:)(and the same for lists and sets), whose async methods are named after the commands, for exampleget(field:),getAll(),append(_:)andmembers(). sendCommandAsRESPrefuses some commands. It throwsCommandErrorwithout sending anything for theSUBSCRIBE/UNSUBSCRIBEfamily (including thePandSvariants),MONITOR,SYNC,PSYNC,CLIENT REPLY OFForSKIP,HELLOwith any protocol version other than 2, andRESET.HELLO,HELLO 2andCLIENT REPLY ONstill go through. Upgrade: usesubscribefor pub/sub, andclose()plus a new connection instead ofRESET. The other commands have no supported replacement.clientReply(type:)is removed (#22). Upgrade: none; replies stay on.- Pub/sub.
readPublished(timeoutSeconds:)is removed, andsubscribe(channels:),subscribe(patterns:),unsubscribe(channels:)andunsubscribe(patterns:)now returnVoid, not aRedisResponse. An emptysubscribelist throwsCommandError. Subscription changes run one at a time, in order.subscribewaits until every command the client has already sent has its reply. From then until the last subscription ends, the client throwsCommandErrorfor every other command,PINGincluded.unsubscribeignores names the client isn't subscribed to, and an empty list means every subscription of that kind.subscribeinsidemulti(body:)is refused. A subscription the server refuses (an ACLNOPERM) waits untilcommandTimeout, because RediStack drops the error reply. Upgrade: iterateclient.messageson a dedicated client, use a second client to publish or run other commands, and setcommandTimeoutif your server restricts channels. - Timeouts.
netTimeoutis gone. Upgrade: passcommandTimeout:(seconds) toRedisClientIdentifier(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 throwsCommandErrorand the connection stays open; if it expires while waiting for the reply (or duringconnect), the call throwsCommandErrorand the connection closes. A non-positive or non-finite value makesconnectthrow.multi(body:)isn't one budget: waiting for another task's transaction,MULTI, each command in the body andEXECeach 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 insidemulti(body:)closes the connection, which discards the server-sideMULTI. 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 withoutclose()now closes its connection immediately instead of leaving it open. Upgrade: nothing for most code; let outstanding commands finish beforeclose()if you need their replies.multi(body:)makes other tasks wait. UntilEXECorDISCARD, 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. Amultion the same client nested inside the body throwsCommandError, and a failedDISCARDno 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, andTask {}created inside the body, inherit the transaction and aren't affected. Two tasks that take transactions on two clients in opposite orders also deadlock, unlesscommandTimeoutis set.- Durations that don't fit throw.
set(expires:),expire(seconds:),expireAt(seconds:)andclientPause(timeoutSeconds:)throwCommandErrorfor NaN, infinity, or more than about 9.2e15 seconds, instead of trapping. Upgrade: handle the error; the methods were alreadythrows. - Only
[UInt8]arrays areRedisValueRepresentable. 3.x applied the conformance to anyArraywhoseElement: ROctaland force-cast it to[UInt8].ROctalremains but no longer adds the conformance. Upgrade: nothing, unless you conformed your own type toROctal; convert its arrays to[UInt8]. - No logging by default. RediStack's logs (every command and its arguments at
debug, every failed command aterror) are discarded unless you passlogger:toRedisClientIdentifier. Upgrade: pass aLoggerif you relied on RediStack's log lines. Atdebugthey include theAUTHpassword 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
$0as.bulkString(nil), and the rewrite mapped that toisNil == 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, sotypeis 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*1headers crashed it with a stack overflow. A count like*4294967295made 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 withCommandErrorfor 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.
subscribesentSUBSCRIBEas 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
EVALreturning{'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.subscribenow 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, aGETfrom another task returnedQUEUED. Two concurrentmulticalls failed with "MULTI calls can not be nested". A first fix used one task-local marker for all clients, soa.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 sendDISCARDorEXEC, leavingMULTIopen so other tasks' commands were queued and a laterEXECcommitted the cancelled writes;multinow closes the connection in that case. - #23: Through
sendCommandAsRESP,HELLO 3switched the connection to RESP3, which can't be parsed, so the connection closed.CLIENT REPLY SKIPshifted every later reply onto the wrong command, and the last command hung.RESETended subscriptions behind RediStack's back, so a laterunsubscribehung. 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, ofAUTHduringconnect, of a command waiting for another task's transaction, and ofsubscribewaiting behind another task'sBLPOP 0.commandTimeoutand 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 waitingsubscribe, letting aPINGout 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,subscribeandunsubscribeboth throwconnectionClosed. - #23:
close()waited forever while any reply was outstanding (another task'sBLPOP 0, or a silent server). Themessagesstream never finished when the connection dropped. A client released withoutclose()left its socket open. After a failedAUTH,connectthrew but left the TCP connection open. - #23: RediStack logged through the global swift-log setup. At
debuglevel that included theAUTHpassword 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,expireAtandclientPausetrapped on non-finite or out-of-range seconds. Perfect-Session passes its idle setting straight intoexpires:. - #23: The
[UInt8]conformance's force cast, which trapped for any otherROctalarray. - #23: Swift 6.2 (Linux and Xcode 26.3) ran no tests at all, because an empty
Tests/LinuxMain.swiftstub was treated as the test entry point.swift testexited 0 without running anything. The stub is gone. - #23: The two live suites ran in parallel against one server, with both running
FLUSHALLand reusingmykey, so four or five tests failed withWRONGTYPEon every run against Redis 7 (also on the pre-release main branch). They now run serially. They also readREDIS_HOST/REDIS_PORTinstead of a hard-coded127.0.0.1:6379, report as skipped rather than passed when disabled, and fail underREDIS_REQUIRE_LIVE=1if 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-errorsturns 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 jazzydocs/and.jazzy.yaml, and theRedisClientSync.swiftstub 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.
AUTHsends the password alone, which authenticates as thedefaultuser. - One connection per client, with no reconnection or pooling. A blocking command such as
BLPOPholds up every later command on the same client, andsubscribewaits 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,SMEMBERSorHGETALLof a bigger collection): they fail withCommandErrorand close the connection. Page through large collections instead. - Host name lookup isn't bounded by
commandTimeout:connectresolves the host with a blocking system call first. - Refused subscriptions wait for the timeout. RediStack drops a
SUBSCRIBEerror reply (such as an ACLNOPERM), so withoutcommandTimeoutthe call waits until the connection closes. Meanwhile the client refuses every command, and later subscription changes queue behind it. messagesis 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, alongsideRedisClient.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
- 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. - Replace
getClient/releaseClientwithtry await RedisClient.connect(withIdentifier:)andtry await client.close(). - Make each call
try await, and catch server errors (RedisError) instead of matching.errorreplies. - Rewrite raw
sendCommandandsendRawCommandcalls to usesendCommandAsRESP, andtoString()calls to use.string. - Replace collection-style
RedisHash/RedisList/RedisSetuse (subscripts, iteration,client.hash(named:)) withRedisHash(client:key:)and its async methods. - For pub/sub, subscribe on a dedicated client and read
client.messages.subscribeno longer returns a value, and a subscribed client refuses other commands. - If you used
netTimeout, passcommandTimeout:toRedisClientIdentifier. Expect the connection to close on a timeout or cancellation, and reconnect after one. - If you want RediStack's logs, pass
logger:, and keep it abovedebuglevel in production. - Code that tests
isNilto 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.