Skip to content

Commit db64423

Browse files
authored
feat(server-dashboard): add Oracle, MongoDB and Redis (#3732)
1 parent aa43041 commit db64423

31 files changed

Lines changed: 1555 additions & 22 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import Foundation
2+
3+
// The driver builds this grid from a SLOWLOG GET reply and the Server Dashboard reads it back by column name.
4+
public enum RedisSlowLogGrid {
5+
public static let id = "ID"
6+
public static let time = "Time"
7+
public static let durationMicroseconds = "Duration (µs)"
8+
public static let command = "Command"
9+
public static let client = "Client"
10+
public static let clientName = "Client Name"
11+
12+
public static let columns = [id, time, durationMicroseconds, command, client, clientName]
13+
public static let columnTypeNames = ["Int64", "Int64", "Int64", "String", "String", "String"]
14+
}

‎apple/Packages/TableProCore/Sources/TableProRedisCore/RedisTopologyDiagnostics.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ public enum RedisServerMode: String, Sendable {
1414
}
1515

1616
public enum RedisServerInfo {
17-
static func value(_ key: String, in info: String) -> String? {
17+
public static func value(_ key: String, in info: String) -> String? {
1818
for line in info.components(separatedBy: .newlines) {
1919
let trimmed = line.trimmingCharacters(in: .whitespaces)
2020
guard trimmed.hasPrefix("\(key):") else { continue }

‎apple/Packages/TableProCore/Tests/TableProRedisCoreTests/RedisConnectionModeTests.swift‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,14 @@ struct RedisServerInfoTests {
111111
#expect(RedisServerInfo.mode(from: info) == .cluster)
112112
}
113113

114+
@Test("A field is read by its whole name, not by a longer one it begins")
115+
func readsAFieldByItsWholeName() {
116+
let info = "# Memory\r\nused_memory_human:1.70M\r\nused_memory:1778512\r\n"
117+
#expect(RedisServerInfo.value("used_memory", in: info) == "1778512")
118+
#expect(RedisServerInfo.value("used_memory_human", in: info) == "1.70M")
119+
#expect(RedisServerInfo.value("used_memory_peak", in: info) == nil)
120+
}
121+
114122
@Test("An absent key is absent, not a wrong answer")
115123
func missingKeyIsNil() {
116124
#expect(RedisServerInfo.mode(from: "# Server\r\n") == nil)

‎apple/Plugins/RedisDriverPlugin/RedisCommandParser.swift‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ enum RedisOperation {
6464
case select(database: Int)
6565
case configGet(parameters: [String])
6666
case configSet(parameter: String, value: String)
67+
case slowlogGet(count: Int?)
6768
case command(args: [RedisArgument])
6869

6970
// Multi
@@ -74,6 +75,10 @@ enum RedisOperation {
7475
/// One command run on the database it names, after which the session goes back to the one it
7576
/// belongs on.
7677
indirect case inDatabase(database: Int, operation: RedisOperation)
78+
79+
/// A server command the app sends on its own, held back rather than queued while the user's
80+
/// MULTI block is open, the way the health probe is.
81+
indirect case outsideBlock(RedisOperation)
7782
}
7883

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

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

215+
case "SLOWLOG":
216+
return parseSlowLog(args, tokens: tokens)
217+
209218
case "KEYBROWSE":
210219
return try parseKeyBrowse(args)
211220

@@ -249,6 +258,26 @@ struct RedisCommandParser {
249258
)
250259
}
251260

261+
/// Only INFO and SLOWLOG GET keep their own grids; anything else goes to the server as typed.
262+
private static func parseOutsideBlock(_ args: [RedisArgument]) throws -> RedisOperation {
263+
guard !args.isEmpty else { throw RedisParseError.emptySyntax }
264+
switch try parse(tokens: args) {
265+
case .info(let sections):
266+
return .outsideBlock(.info(sections: sections))
267+
case .slowlogGet(let count):
268+
return .outsideBlock(.slowlogGet(count: count))
269+
default:
270+
return .outsideBlock(.command(args: args))
271+
}
272+
}
273+
274+
private static func parseSlowLog(_ args: [RedisArgument], tokens: [RedisArgument]) -> RedisOperation {
275+
guard args.first?.text.uppercased() == "GET", args.count <= 2 else { return .command(args: tokens) }
276+
guard args.count == 2 else { return .slowlogGet(count: nil) }
277+
guard let count = Int(args[1].text) else { return .command(args: tokens) }
278+
return .slowlogGet(count: count)
279+
}
280+
252281
/// `DB` names the database the read reaches whichever database the session is on: a refresh,
253282
/// a later page and an export all read the database the row names rather than the one the
254283
/// session last moved to, and the key tree lists the database the sidebar shows.

‎apple/Plugins/RedisDriverPlugin/RedisCommandRouting.swift‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,8 @@ struct RedisCommandRouting: Sendable {
308308
add(spec("memory", 0, 0, 0))
309309
add(spec("memory|stats", 0, 0, 0, request: .allShards, response: .special))
310310
add(spec("memory|usage", 2, 2, 1, readOnly: true))
311+
add(spec("slowlog", 0, 0, 0))
312+
add(spec("slowlog|get", 0, 0, 0, request: .allNodes))
311313
return table
312314
}()
313315
}

‎apple/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,19 @@ extension RedisPluginDriver {
6969
case .xrange, .xlen:
7070
return try await executeStreamOperation(operation, connection: conn, startTime: startTime)
7171

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

7576
case .inDatabase(let database, let operation):
7677
return try await conn.withDatabase(database) {
7778
try await runOperation(operation, connection: conn, startTime: startTime)
7879
}
80+
81+
case .outsideBlock(let operation):
82+
return try await executeServerOperation(
83+
operation, connection: conn, startTime: startTime, scope: .outsideBlock
84+
)
7985
}
8086
}
8187

@@ -473,7 +479,8 @@ extension RedisPluginDriver {
473479
func executeServerOperation(
474480
_ operation: RedisOperation,
475481
connection conn: any RedisCommandChannel,
476-
startTime: Date
482+
startTime: Date,
483+
scope: RedisCommandScope = .session
477484
) async throws -> PluginQueryResult {
478485
switch operation {
479486
case .ping:
@@ -487,7 +494,7 @@ extension RedisPluginDriver {
487494
)
488495

489496
case .info(let sections):
490-
let result = try await conn.run(["INFO"] + sections)
497+
let result = try await conn.run(["INFO"] + sections, scope: scope)
491498
let infoText = result.stringValue ?? String(describing: result)
492499
return PluginQueryResult(
493500
columns: ["info"],
@@ -524,8 +531,12 @@ extension RedisPluginDriver {
524531
try await conn.run(["CONFIG", "SET", parameter, value])
525532
return buildStatusResult("OK", startTime: startTime)
526533

534+
case .slowlogGet(let count):
535+
let result = try await conn.run(["SLOWLOG", "GET"] + (count.map { [String($0)] } ?? []), scope: scope)
536+
return RedisReplyGrid.slowLog(result).queryResult(startTime: startTime)
537+
527538
case .command(let args):
528-
let result = try await conn.run(args.asRedisArguments)
539+
let result = try await conn.run(args.asRedisArguments, scope: scope)
529540
return RedisReplyGrid.generic(result).queryResult(startTime: startTime)
530541

531542
case .multi:

‎apple/Plugins/RedisDriverPlugin/RedisReplyGrid.swift‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import Foundation
77
import TableProPluginKit
8+
import TableProRedisCore
89

910
struct RedisReplyGrid: Equatable {
1011
let columns: [String]
@@ -77,6 +78,14 @@ struct RedisReplyGrid: Equatable {
7778
)
7879
}
7980

81+
static func slowLog(_ reply: RedisReply) -> RedisReplyGrid {
82+
RedisReplyGrid(
83+
columns: RedisSlowLogGrid.columns,
84+
columnTypeNames: RedisSlowLogGrid.columnTypeNames,
85+
rows: (reply.arrayValue ?? []).compactMap(slowLogRow)
86+
)
87+
}
88+
8089
static func generic(_ reply: RedisReply) -> RedisReplyGrid {
8190
switch reply {
8291
case .integer(let value):
@@ -104,6 +113,25 @@ struct RedisReplyGrid: Equatable {
104113
}
105114
}
106115

116+
// Redis before 4.0 sends four fields per entry, without the client's address and name.
117+
private static func slowLogRow(_ entry: RedisReply) -> PluginRow? {
118+
guard let fields = entry.arrayValue, fields.count >= 4 else { return nil }
119+
let command = (fields[3].arrayValue ?? []).map(quotedArgument).joined(separator: " ")
120+
return [
121+
.text(fields[0].displayText),
122+
.text(fields[1].displayText),
123+
.text(fields[2].displayText),
124+
.text(command),
125+
fields.count > 4 ? .text(fields[4].displayText) : .null,
126+
fields.count > 5 ? .text(fields[5].displayText) : .null,
127+
]
128+
}
129+
130+
private static func quotedArgument(_ argument: RedisReply) -> String {
131+
if case .data(let bytes) = argument { return RedisArgumentCodec.quote(bytes) }
132+
return RedisArgumentCodec.quote(argument.displayText)
133+
}
134+
107135
private static func streamRow(_ entry: RedisReply) -> PluginRow? {
108136
guard let parts = entry.arrayValue, parts.count >= 2, let fields = parts[1].arrayValue else {
109137
return nil

‎apple/Plugins/RedisDriverTests/RedisCommandRoutingTests.swift‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,14 @@ struct RedisCommandRoutingPolicyTests {
122122
#expect(routing.spec(for: args("INFO"))?.responsePolicy == .special)
123123
}
124124

125+
/// Redis 6 reports no tips, and without this entry a cluster's slow log came from one node.
126+
@Test("SLOWLOG GET reaches every node and concatenates, while SLOWLOG LEN stays on one")
127+
func slowLogGet() {
128+
#expect(routing.spec(for: args("SLOWLOG", "GET", "20"))?.clusterFanOut == .everyNode)
129+
#expect(routing.spec(for: args("SLOWLOG", "GET"))?.responsePolicy == nil)
130+
#expect(routing.spec(for: args("SLOWLOG", "LEN"))?.clusterFanOut == .single)
131+
}
132+
125133
@Test("DEL and MGET are multi-shard")
126134
func multiShard() {
127135
#expect(routing.spec(for: args("DEL", "a"))?.requestPolicy == .multiShard)
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
//
2+
// RedisDashboardCommandTests.swift
3+
// TableProTests
4+
//
5+
// The Server Dashboard reads SLOWLOG GET as a grid and sends every command as OUTSIDEBLOCK, so a
6+
// refresh is held back while the user has a MULTI block open instead of joining it.
7+
//
8+
9+
import Foundation
10+
import TableProPluginKit
11+
import TableProRedisCore
12+
import Testing
13+
14+
struct RedisSlowLogParseTests {
15+
@Test("SLOWLOG GET, with or without a count, is typed", arguments: [
16+
("SLOWLOG GET", nil),
17+
("slowlog get 20", 20),
18+
] as [(String, Int?)])
19+
func slowLogGetIsTyped(statement: String, count: Int?) throws {
20+
guard case .slowlogGet(let parsed) = try RedisCommandParser.parse(statement) else {
21+
Issue.record("Expected .slowlogGet for \(statement)")
22+
return
23+
}
24+
#expect(parsed == count)
25+
}
26+
27+
@Test("Any other SLOWLOG form goes to the server as typed", arguments: [
28+
"SLOWLOG LEN", "SLOWLOG RESET", "SLOWLOG GET abc", "SLOWLOG GET 1 2", "SLOWLOG",
29+
])
30+
func otherFormsAreVerbatim(statement: String) throws {
31+
guard case .command = try RedisCommandParser.parse(statement) else {
32+
Issue.record("Expected .command for \(statement)")
33+
return
34+
}
35+
}
36+
}
37+
38+
struct RedisOutsideBlockParseTests {
39+
@Test("OUTSIDEBLOCK keeps the grid of INFO and SLOWLOG GET")
40+
func keepsTypedServerGrids() throws {
41+
guard case .outsideBlock(.info(let sections)) = try RedisCommandParser.parse("OUTSIDEBLOCK INFO") else {
42+
Issue.record("Expected INFO outside the block")
43+
return
44+
}
45+
#expect(sections.isEmpty)
46+
guard case .outsideBlock(.slowlogGet(let count)) = try RedisCommandParser.parse("OUTSIDEBLOCK SLOWLOG GET 20") else {
47+
Issue.record("Expected SLOWLOG GET outside the block")
48+
return
49+
}
50+
#expect(count == 20)
51+
}
52+
53+
@Test("Any other command is sent as typed", arguments: [
54+
"OUTSIDEBLOCK CLIENT LIST", "OUTSIDEBLOCK CLIENT KILL ID 21", "outsideblock GET key",
55+
])
56+
func otherCommandsAreVerbatim(statement: String) throws {
57+
guard case .outsideBlock(.command(let args)) = try RedisCommandParser.parse(statement) else {
58+
Issue.record("Expected a verbatim command outside the block for \(statement)")
59+
return
60+
}
61+
#expect(args.map { $0.text } == Array(statement.split(separator: " ").dropFirst().map(String.init)))
62+
}
63+
64+
@Test("OUTSIDEBLOCK with nothing after it is refused")
65+
func emptyIsRefused() {
66+
#expect(throws: RedisParseError.self) { try RedisCommandParser.parse("OUTSIDEBLOCK") }
67+
}
68+
}
69+
70+
struct RedisSlowLogGridTests {
71+
@Test("An entry becomes one row, its arguments quoted the way the console reads them")
72+
func entryBecomesARow() {
73+
let reply = RedisReply.array([
74+
.array([
75+
.integer(9), .integer(1_792_537_207), .integer(626),
76+
.array([.string("SET"), .string("foo"), .string("a b,c]")]),
77+
.string("127.0.0.1:59713"), .string("worker"), .integer(3),
78+
]),
79+
])
80+
let grid = RedisReplyGrid.slowLog(reply)
81+
#expect(grid.columns == RedisSlowLogGrid.columns)
82+
#expect(grid.columnTypeNames.count == grid.columns.count)
83+
#expect(grid.rows == [[
84+
.text("9"), .text("1792537207"), .text("626"), .text(#"SET foo "a b,c]""#),
85+
.text("127.0.0.1:59713"), .text("worker"),
86+
]])
87+
}
88+
89+
@Test("Redis before 4.0 sends no client, and a malformed entry is skipped")
90+
func olderAndMalformedEntries() {
91+
let reply = RedisReply.array([
92+
.array([.integer(1), .integer(1_500_000_000), .integer(20_000), .array([.string("KEYS"), .string("*")])]),
93+
.array([.integer(2)]),
94+
.string("not an entry"),
95+
])
96+
#expect(RedisReplyGrid.slowLog(reply).rows == [[
97+
.text("1"), .text("1500000000"), .text("20000"), .text("KEYS *"), .null, .null,
98+
]])
99+
}
100+
}
101+
102+
struct RedisOutsideBlockExecutionTests {
103+
private func driver() -> RedisPluginDriver {
104+
RedisPluginDriver(config: DriverConnectionConfig(
105+
host: "127.0.0.1", port: 6_379, username: "", password: "", database: "0"
106+
))
107+
}
108+
109+
@Test("A dashboard command runs outside the block scope")
110+
func runsOutsideTheBlock() async throws {
111+
let channel = StubRedisChannel([.string("id=21 addr=10.0.0.4:51022 cmd=get")])
112+
let result = try await driver().executeOperation(
113+
try RedisCommandParser.parse("OUTSIDEBLOCK CLIENT LIST"), connection: channel, startTime: Date()
114+
)
115+
#expect(channel.sentCommands == [["CLIENT", "LIST"]])
116+
#expect(channel.sentScopes == [.outsideBlock])
117+
#expect(result.rows.first?.first?.asText?.hasPrefix("id=21") == true)
118+
}
119+
120+
@Test("With the user's MULTI block open the command is held back, never queued into it")
121+
func heldBackWhileTheBlockIsOpen() async throws {
122+
let channel = StubRedisChannel([])
123+
channel.observeOpenBlock()
124+
await #expect(throws: RedisHeldBackCommand(command: "INFO", held: .openBlock)) {
125+
_ = try await driver().executeOperation(
126+
try RedisCommandParser.parse("OUTSIDEBLOCK INFO"), connection: channel, startTime: Date()
127+
)
128+
}
129+
#expect(channel.sentCommands.isEmpty)
130+
}
131+
132+
@Test("The same command typed by the user still joins the user's block")
133+
func typedCommandStillQueues() async throws {
134+
let channel = StubRedisChannel([.status("QUEUED")])
135+
channel.observeOpenBlock()
136+
let result = try await driver().executeOperation(
137+
try RedisCommandParser.parse("SLOWLOG GET 5"), connection: channel, startTime: Date()
138+
)
139+
#expect(channel.sentScopes == [.session])
140+
#expect(result.rows.first?.first?.asText == "QUEUED")
141+
}
142+
}

‎apple/TablePro/Core/Database/DatabaseDriver.swift‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,11 @@ protocol DatabaseDriver: AnyObject, Sendable {
101101
/// rest of the fetch, which for some drivers cancels the statement on the server.
102102
func executeBoundedQuery(query: String, rowCap: Int) async throws -> QueryResult?
103103

104+
/// Runs a statement the app wrote to read, telling the driver it may only read. A driver that
105+
/// evaluates statements in a scripting engine with state refuses any write the script reaches,
106+
/// whatever an earlier statement redefined.
107+
func executeRead(query: String) async throws -> QueryResult
108+
104109
/// Whether ``executeBatch(query:rowCap:parameters:)`` sends a text to the server whole and answers with every
105110
/// result set it produced.
106111
var supportsResultSetBatches: Bool { get }
@@ -473,6 +478,10 @@ extension DatabaseDriver {
473478

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

481+
func executeRead(query: String) async throws -> QueryResult {
482+
try await execute(query: query)
483+
}
484+
476485
var supportsResultSetBatches: Bool { false }
477486

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

0 commit comments

Comments
 (0)