Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Assistant conversations belong to one connection, and outlive the window that opened them.
- iCloud sync and usage data on iPhone and iPad stay off until you turn them on.
- Group rows in the iOS connection list take swipe actions and show even when no connection is saved.
- Typed entry beside the stepper for number settings in the connection form.

### Removed

Expand Down Expand Up @@ -225,6 +226,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Compare & Sync showing an Oracle unit missing the `;` after its `END` as identical.
- SSH jump hosts dropped from a connection synced to iPhone and iPad, and that connection then skipped on the way back.
- An SSH tunnel pinned to port 22, and its auth method read back as Password, after a round trip through iPhone and iPad.
- Redis database list failing on servers that refuse `CONFIG` or `INFO`, such as AWS ElastiCache and Azure Cache for Redis. (#3036)
- Empty tab after clicking a Redis database the server refuses to switch to.
- No wrong-mode error when a Standalone Redis connection points at a Valkey 8 or later Sentinel or cluster node.
- Redis `MULTI` blocks aborted, or padded with extra replies, by the sidebar, the key browser and the connection check.
- A Redis `MULTI` block or `WATCH` lost without notice when the connection dropped.
- Redis row counts, statistics, DDL preview, export and grid edits using the session's current database instead of the row's own.
- Refreshing a Redis database tab after a refused switch showing another database's keys.
- Redis key grid showing type UNKNOWN, TTL -1 and empty collections for keys an ACL user cannot read.
- Redis Cluster deletes and key counts reported as complete when a shard refused them, and `SCRIPT EXISTS` answering 0.
- Database Index in the Redis connection form stopping at 15.
- Empty Redis key list on iPhone and iPad when the server refuses the scan or a `MULTI` block is open.
- Explain Query failing on Redis with a `DEBUG` command error, and enabled for databases with no query plan.
- Redis key tree showing "No items" when the server refuses the key scan or a `MULTI` block is open.

### Security

Expand Down
58 changes: 44 additions & 14 deletions Plugins/RedisDriverPlugin/RedisClusterAggregator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,36 +11,66 @@
import Foundation

enum RedisClusterAggregator {
/// A shard that refused its part, or queued it into an open `MULTI` block, wins over every
/// policy but one_succeeded, whose whole meaning is that some shards may fail. Folding it in
/// instead counted it as zero: a split `DEL` one shard refused reported the other shard's
/// deletions as the total, and `DBSIZE` reported part of the keyspace as all of it.
static func combine(_ replies: [RedisReply], policy: RedisResponsePolicy?) -> RedisReply {
guard let first = replies.first else { return .array([]) }
guard replies.count > 1 else { return first }
if policy != .oneSucceeded, let failure = firstNonAnswer(in: replies) { return failure }

switch policy {
case .aggSum:
return .integer(replies.reduce(Int64(0)) { $0 + numericValue($1) })
return aggregated(replies) { $0.reduce(0, +) }
case .aggMin:
return .integer(replies.map(numericValue).min() ?? 0)
return aggregated(replies) { $0.min() ?? 0 }
case .aggMax:
return .integer(replies.map(numericValue).max() ?? 0)
return aggregated(replies) { $0.max() ?? 0 }
case .aggLogicalAnd:
return .integer(replies.allSatisfy { numericValue($0) != 0 } ? 1 : 0)
return aggregated(replies) { $0.allSatisfy { $0 != 0 } ? 1 : 0 }
case .aggLogicalOr:
return .integer(replies.contains { numericValue($0) != 0 } ? 1 : 0)
return aggregated(replies) { $0.contains { $0 != 0 } ? 1 : 0 }
case .oneSucceeded:
return replies.first { !$0.isError } ?? first
case .allSucceeded:
if let failure = replies.first(where: { $0.isError }) { return failure }
return replies.first { !$0.isError } ?? first
return first
case .special, .none:
return concatenated(replies)
}
}

/// The first shard error in the order the shards were asked, else the first `+QUEUED`.
static func firstNonAnswer(in replies: [RedisReply]) -> RedisReply? {
replies.first(where: \.isError) ?? replies.first(where: \.isQueued)
}

/// A reply the policy cannot count is handed back whole rather than as a number made up for
/// it, the way a keyed reply the planner cannot scatter is.
private static func aggregated(_ replies: [RedisReply], _ reduce: ([Int64]) -> Int64) -> RedisReply {
folded(replies, reduce) ?? .array(replies)
}

/// Integers fold to one integer. Arrays of one width fold position by position, which is
/// what `SCRIPT EXISTS` (one flag per script) and `WAITAOF` (local and replica counts) need.
private static func folded(_ replies: [RedisReply], _ reduce: ([Int64]) -> Int64) -> RedisReply? {
let numbers = replies.compactMap(numericValue)
if numbers.count == replies.count { return .integer(reduce(numbers)) }

let arrays = replies.compactMap(\.arrayValue)
guard arrays.count == replies.count, let width = arrays.first?.count,
arrays.allSatisfy({ $0.count == width }) else { return nil }
var elements: [RedisReply] = []
elements.reserveCapacity(width)
for position in 0 ..< width {
guard let element = folded(arrays.map { $0[position] }, reduce) else { return nil }
elements.append(element)
}
return .array(elements)
}

/// The no-policy default for a keyless fan-out: pack every shard's nested reply into one.
/// A shard that answered with an error still wins, because a partial answer read as complete
/// is worse than a visible failure.
static func concatenated(_ replies: [RedisReply]) -> RedisReply {
if let failure = replies.first(where: { $0.isError }) { return failure }
private static func concatenated(_ replies: [RedisReply]) -> RedisReply {
var merged: [RedisReply] = []
var sawArray = false
for reply in replies {
Expand All @@ -57,11 +87,11 @@ enum RedisClusterAggregator {
return .array(merged)
}

private static func numericValue(_ reply: RedisReply) -> Int64 {
private static func numericValue(_ reply: RedisReply) -> Int64? {
switch reply {
case .integer(let value): return value
case .string(let text), .status(let text): return Int64(text) ?? 0
default: return 0
case .string(let text), .status(let text): return Int64(text)
default: return nil
}
}
}
Loading
Loading