Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import Foundation

// The driver builds this grid from a SLOWLOG GET reply and the Server Dashboard reads it back by column name.
public enum RedisSlowLogGrid {
public static let id = "ID"
public static let time = "Time"
public static let durationMicroseconds = "Duration (µs)"
public static let command = "Command"
public static let client = "Client"
public static let clientName = "Client Name"

public static let columns = [id, time, durationMicroseconds, command, client, clientName]
public static let columnTypeNames = ["Int64", "Int64", "Int64", "String", "String", "String"]
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public enum RedisServerMode: String, Sendable {
}

public enum RedisServerInfo {
static func value(_ key: String, in info: String) -> String? {
public static func value(_ key: String, in info: String) -> String? {
for line in info.components(separatedBy: .newlines) {
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard trimmed.hasPrefix("\(key):") else { continue }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,14 @@ struct RedisServerInfoTests {
#expect(RedisServerInfo.mode(from: info) == .cluster)
}

@Test("A field is read by its whole name, not by a longer one it begins")
func readsAFieldByItsWholeName() {
let info = "# Memory\r\nused_memory_human:1.70M\r\nused_memory:1778512\r\n"
#expect(RedisServerInfo.value("used_memory", in: info) == "1778512")
#expect(RedisServerInfo.value("used_memory_human", in: info) == "1.70M")
#expect(RedisServerInfo.value("used_memory_peak", in: info) == nil)
}

@Test("An absent key is absent, not a wrong answer")
func missingKeyIsNil() {
#expect(RedisServerInfo.mode(from: "# Server\r\n") == nil)
Expand Down
29 changes: 29 additions & 0 deletions apple/Plugins/RedisDriverPlugin/RedisCommandParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ enum RedisOperation {
case select(database: Int)
case configGet(parameters: [String])
case configSet(parameter: String, value: String)
case slowlogGet(count: Int?)
case command(args: [RedisArgument])

// Multi
Expand All @@ -74,6 +75,10 @@ enum RedisOperation {
/// One command run on the database it names, after which the session goes back to the one it
/// belongs on.
indirect case inDatabase(database: Int, operation: RedisOperation)

/// A server command the app sends on its own, held back rather than queued while the user's
/// MULTI block is open, the way the health probe is.
indirect case outsideBlock(RedisOperation)
}

/// Options for SET command
Expand Down Expand Up @@ -160,6 +165,7 @@ struct RedisCommandParser {
let command = first.text.uppercased()
let args = Array(tokens.dropFirst())
if command == "DB" { return try parseInDatabase(args) }
if command == "OUTSIDEBLOCK" { return try parseOutsideBlock(args) }

if let typedCount = typedArgumentCount[command], args.count > typedCount {
return .command(args: tokens)
Expand Down Expand Up @@ -206,6 +212,9 @@ struct RedisCommandParser {
"MULTI", "EXEC", "DISCARD", "AUTH":
return try parseServerCommand(command, args: args, tokens: tokens)

case "SLOWLOG":
return parseSlowLog(args, tokens: tokens)

case "KEYBROWSE":
return try parseKeyBrowse(args)

Expand Down Expand Up @@ -249,6 +258,26 @@ struct RedisCommandParser {
)
}

/// Only INFO and SLOWLOG GET keep their own grids; anything else goes to the server as typed.
private static func parseOutsideBlock(_ args: [RedisArgument]) throws -> RedisOperation {
guard !args.isEmpty else { throw RedisParseError.emptySyntax }
switch try parse(tokens: args) {
case .info(let sections):
return .outsideBlock(.info(sections: sections))
case .slowlogGet(let count):
return .outsideBlock(.slowlogGet(count: count))
default:
return .outsideBlock(.command(args: args))
}
}

private static func parseSlowLog(_ args: [RedisArgument], tokens: [RedisArgument]) -> RedisOperation {
guard args.first?.text.uppercased() == "GET", args.count <= 2 else { return .command(args: tokens) }
guard args.count == 2 else { return .slowlogGet(count: nil) }
guard let count = Int(args[1].text) else { return .command(args: tokens) }
return .slowlogGet(count: count)
}

/// `DB` names the database the read reaches whichever database the session is on: a refresh,
/// a later page and an export all read the database the row names rather than the one the
/// session last moved to, and the key tree lists the database the sidebar shows.
Expand Down
2 changes: 2 additions & 0 deletions apple/Plugins/RedisDriverPlugin/RedisCommandRouting.swift
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,8 @@ struct RedisCommandRouting: Sendable {
add(spec("memory", 0, 0, 0))
add(spec("memory|stats", 0, 0, 0, request: .allShards, response: .special))
add(spec("memory|usage", 2, 2, 1, readOnly: true))
add(spec("slowlog", 0, 0, 0))
add(spec("slowlog|get", 0, 0, 0, request: .allNodes))
return table
}()
}
19 changes: 15 additions & 4 deletions apple/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,19 @@ extension RedisPluginDriver {
case .xrange, .xlen:
return try await executeStreamOperation(operation, connection: conn, startTime: startTime)

case .ping, .info, .dbsize, .flushdb, .select, .configGet, .configSet, .command, .multi, .exec, .discard:
case .ping, .info, .dbsize, .flushdb, .select, .configGet, .configSet, .slowlogGet, .command,
.multi, .exec, .discard:
return try await executeServerOperation(operation, connection: conn, startTime: startTime)

case .inDatabase(let database, let operation):
return try await conn.withDatabase(database) {
try await runOperation(operation, connection: conn, startTime: startTime)
}

case .outsideBlock(let operation):
return try await executeServerOperation(
operation, connection: conn, startTime: startTime, scope: .outsideBlock
)
}
}

Expand Down Expand Up @@ -473,7 +479,8 @@ extension RedisPluginDriver {
func executeServerOperation(
_ operation: RedisOperation,
connection conn: any RedisCommandChannel,
startTime: Date
startTime: Date,
scope: RedisCommandScope = .session
) async throws -> PluginQueryResult {
switch operation {
case .ping:
Expand All @@ -487,7 +494,7 @@ extension RedisPluginDriver {
)

case .info(let sections):
let result = try await conn.run(["INFO"] + sections)
let result = try await conn.run(["INFO"] + sections, scope: scope)
let infoText = result.stringValue ?? String(describing: result)
return PluginQueryResult(
columns: ["info"],
Expand Down Expand Up @@ -524,8 +531,12 @@ extension RedisPluginDriver {
try await conn.run(["CONFIG", "SET", parameter, value])
return buildStatusResult("OK", startTime: startTime)

case .slowlogGet(let count):
let result = try await conn.run(["SLOWLOG", "GET"] + (count.map { [String($0)] } ?? []), scope: scope)
return RedisReplyGrid.slowLog(result).queryResult(startTime: startTime)

case .command(let args):
let result = try await conn.run(args.asRedisArguments)
let result = try await conn.run(args.asRedisArguments, scope: scope)
return RedisReplyGrid.generic(result).queryResult(startTime: startTime)

case .multi:
Expand Down
28 changes: 28 additions & 0 deletions apple/Plugins/RedisDriverPlugin/RedisReplyGrid.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import Foundation
import TableProPluginKit
import TableProRedisCore

struct RedisReplyGrid: Equatable {
let columns: [String]
Expand Down Expand Up @@ -77,6 +78,14 @@ struct RedisReplyGrid: Equatable {
)
}

static func slowLog(_ reply: RedisReply) -> RedisReplyGrid {
RedisReplyGrid(
columns: RedisSlowLogGrid.columns,
columnTypeNames: RedisSlowLogGrid.columnTypeNames,
rows: (reply.arrayValue ?? []).compactMap(slowLogRow)
)
}

static func generic(_ reply: RedisReply) -> RedisReplyGrid {
switch reply {
case .integer(let value):
Expand Down Expand Up @@ -104,6 +113,25 @@ struct RedisReplyGrid: Equatable {
}
}

// Redis before 4.0 sends four fields per entry, without the client's address and name.
private static func slowLogRow(_ entry: RedisReply) -> PluginRow? {
guard let fields = entry.arrayValue, fields.count >= 4 else { return nil }
let command = (fields[3].arrayValue ?? []).map(quotedArgument).joined(separator: " ")
return [
.text(fields[0].displayText),
.text(fields[1].displayText),
.text(fields[2].displayText),
.text(command),
fields.count > 4 ? .text(fields[4].displayText) : .null,
fields.count > 5 ? .text(fields[5].displayText) : .null,
]
}

private static func quotedArgument(_ argument: RedisReply) -> String {
if case .data(let bytes) = argument { return RedisArgumentCodec.quote(bytes) }
return RedisArgumentCodec.quote(argument.displayText)
}

private static func streamRow(_ entry: RedisReply) -> PluginRow? {
guard let parts = entry.arrayValue, parts.count >= 2, let fields = parts[1].arrayValue else {
return nil
Expand Down
8 changes: 8 additions & 0 deletions apple/Plugins/RedisDriverTests/RedisCommandRoutingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,14 @@ struct RedisCommandRoutingPolicyTests {
#expect(routing.spec(for: args("INFO"))?.responsePolicy == .special)
}

/// Redis 6 reports no tips, and without this entry a cluster's slow log came from one node.
@Test("SLOWLOG GET reaches every node and concatenates, while SLOWLOG LEN stays on one")
func slowLogGet() {
#expect(routing.spec(for: args("SLOWLOG", "GET", "20"))?.clusterFanOut == .everyNode)
#expect(routing.spec(for: args("SLOWLOG", "GET"))?.responsePolicy == nil)
#expect(routing.spec(for: args("SLOWLOG", "LEN"))?.clusterFanOut == .single)
}

@Test("DEL and MGET are multi-shard")
func multiShard() {
#expect(routing.spec(for: args("DEL", "a"))?.requestPolicy == .multiShard)
Expand Down
142 changes: 142 additions & 0 deletions apple/Plugins/RedisDriverTests/RedisDashboardCommandTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
//
// RedisDashboardCommandTests.swift
// TableProTests
//
// The Server Dashboard reads SLOWLOG GET as a grid and sends every command as OUTSIDEBLOCK, so a
// refresh is held back while the user has a MULTI block open instead of joining it.
//

import Foundation
import TableProPluginKit
import TableProRedisCore
import Testing

struct RedisSlowLogParseTests {
@Test("SLOWLOG GET, with or without a count, is typed", arguments: [
("SLOWLOG GET", nil),
("slowlog get 20", 20),
] as [(String, Int?)])
func slowLogGetIsTyped(statement: String, count: Int?) throws {
guard case .slowlogGet(let parsed) = try RedisCommandParser.parse(statement) else {
Issue.record("Expected .slowlogGet for \(statement)")
return
}
#expect(parsed == count)
}

@Test("Any other SLOWLOG form goes to the server as typed", arguments: [
"SLOWLOG LEN", "SLOWLOG RESET", "SLOWLOG GET abc", "SLOWLOG GET 1 2", "SLOWLOG",
])
func otherFormsAreVerbatim(statement: String) throws {
guard case .command = try RedisCommandParser.parse(statement) else {
Issue.record("Expected .command for \(statement)")
return
}
}
}

struct RedisOutsideBlockParseTests {
@Test("OUTSIDEBLOCK keeps the grid of INFO and SLOWLOG GET")
func keepsTypedServerGrids() throws {
guard case .outsideBlock(.info(let sections)) = try RedisCommandParser.parse("OUTSIDEBLOCK INFO") else {
Issue.record("Expected INFO outside the block")
return
}
#expect(sections.isEmpty)
guard case .outsideBlock(.slowlogGet(let count)) = try RedisCommandParser.parse("OUTSIDEBLOCK SLOWLOG GET 20") else {
Issue.record("Expected SLOWLOG GET outside the block")
return
}
#expect(count == 20)
}

@Test("Any other command is sent as typed", arguments: [
"OUTSIDEBLOCK CLIENT LIST", "OUTSIDEBLOCK CLIENT KILL ID 21", "outsideblock GET key",
])
func otherCommandsAreVerbatim(statement: String) throws {
guard case .outsideBlock(.command(let args)) = try RedisCommandParser.parse(statement) else {
Issue.record("Expected a verbatim command outside the block for \(statement)")
return
}
#expect(args.map { $0.text } == Array(statement.split(separator: " ").dropFirst().map(String.init)))
}

@Test("OUTSIDEBLOCK with nothing after it is refused")
func emptyIsRefused() {
#expect(throws: RedisParseError.self) { try RedisCommandParser.parse("OUTSIDEBLOCK") }
}
}

struct RedisSlowLogGridTests {
@Test("An entry becomes one row, its arguments quoted the way the console reads them")
func entryBecomesARow() {
let reply = RedisReply.array([
.array([
.integer(9), .integer(1_792_537_207), .integer(626),
.array([.string("SET"), .string("foo"), .string("a b,c]")]),
.string("127.0.0.1:59713"), .string("worker"), .integer(3),
]),
])
let grid = RedisReplyGrid.slowLog(reply)
#expect(grid.columns == RedisSlowLogGrid.columns)
#expect(grid.columnTypeNames.count == grid.columns.count)
#expect(grid.rows == [[
.text("9"), .text("1792537207"), .text("626"), .text(#"SET foo "a b,c]""#),
.text("127.0.0.1:59713"), .text("worker"),
]])
}

@Test("Redis before 4.0 sends no client, and a malformed entry is skipped")
func olderAndMalformedEntries() {
let reply = RedisReply.array([
.array([.integer(1), .integer(1_500_000_000), .integer(20_000), .array([.string("KEYS"), .string("*")])]),
.array([.integer(2)]),
.string("not an entry"),
])
#expect(RedisReplyGrid.slowLog(reply).rows == [[
.text("1"), .text("1500000000"), .text("20000"), .text("KEYS *"), .null, .null,
]])
}
}

struct RedisOutsideBlockExecutionTests {
private func driver() -> RedisPluginDriver {
RedisPluginDriver(config: DriverConnectionConfig(
host: "127.0.0.1", port: 6_379, username: "", password: "", database: "0"
))
}

@Test("A dashboard command runs outside the block scope")
func runsOutsideTheBlock() async throws {
let channel = StubRedisChannel([.string("id=21 addr=10.0.0.4:51022 cmd=get")])
let result = try await driver().executeOperation(
try RedisCommandParser.parse("OUTSIDEBLOCK CLIENT LIST"), connection: channel, startTime: Date()
)
#expect(channel.sentCommands == [["CLIENT", "LIST"]])
#expect(channel.sentScopes == [.outsideBlock])
#expect(result.rows.first?.first?.asText?.hasPrefix("id=21") == true)
}

@Test("With the user's MULTI block open the command is held back, never queued into it")
func heldBackWhileTheBlockIsOpen() async throws {
let channel = StubRedisChannel([])
channel.observeOpenBlock()
await #expect(throws: RedisHeldBackCommand(command: "INFO", held: .openBlock)) {
_ = try await driver().executeOperation(
try RedisCommandParser.parse("OUTSIDEBLOCK INFO"), connection: channel, startTime: Date()
)
}
#expect(channel.sentCommands.isEmpty)
}

@Test("The same command typed by the user still joins the user's block")
func typedCommandStillQueues() async throws {
let channel = StubRedisChannel([.status("QUEUED")])
channel.observeOpenBlock()
let result = try await driver().executeOperation(
try RedisCommandParser.parse("SLOWLOG GET 5"), connection: channel, startTime: Date()
)
#expect(channel.sentScopes == [.session])
#expect(result.rows.first?.first?.asText == "QUEUED")
}
}
9 changes: 9 additions & 0 deletions apple/TablePro/Core/Database/DatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ protocol DatabaseDriver: AnyObject, Sendable {
/// rest of the fetch, which for some drivers cancels the statement on the server.
func executeBoundedQuery(query: String, rowCap: Int) async throws -> QueryResult?

/// Runs a statement the app wrote to read, telling the driver it may only read. A driver that
/// evaluates statements in a scripting engine with state refuses any write the script reaches,
/// whatever an earlier statement redefined.
func executeRead(query: String) async throws -> QueryResult

/// Whether ``executeBatch(query:rowCap:parameters:)`` sends a text to the server whole and answers with every
/// result set it produced.
var supportsResultSetBatches: Bool { get }
Expand Down Expand Up @@ -473,6 +478,10 @@ extension DatabaseDriver {

func executeBoundedQuery(query: String, rowCap: Int) async throws -> QueryResult? { nil }

func executeRead(query: String) async throws -> QueryResult {
try await execute(query: query)
}

var supportsResultSetBatches: Bool { false }

func executeBatch(query: String, rowCap: Int?, parameters: [Any?]?) async throws -> QueryBatchResult? { nil }
Expand Down
Loading
Loading