Skip to content
Open
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Pre-connect script failures sometimes reported without the script's own error message.
- Failed MongoDB statements, including writes the server rejected, reported as successful with an empty result.
- MongoDB **Write Concern** setting ignored by every write except inserts, data grid saves included.
- `writeConcern` option ignored by MongoDB shell writes, and `ordered` by `insertMany`.
- `remove(filter, {justOne: true})` in the MongoDB shell deleting every matching document.
- MongoDB write that stopped part-way reported without saying documents had already changed.
- Empty error message when a MongoDB `insertMany` stopped at an oversized document.
- `tablepro-mcp` crashing when its standard input was non-blocking.
- `tablepro-mcp` using a full CPU core, or crashing, when its standard output or error was non-blocking.
- Server connections piling up while browsing many databases or schemas, and staying open after a failed connect. (#3103)
Expand Down
95 changes: 86 additions & 9 deletions Plugins/MongoDBDriverPlugin/MongoDBConnection+ScriptHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,45 @@ import os

extension MongoDBConnection {
func scriptRunCommand(client: OpaquePointer, command: String, database: String?) throws -> String {
let result = try sendCommand(client: client, command: command, database: database)
try checkCancelled()
guard result.ok else { throw makeError(result.error) }
return result.reply
}

/// Runs a write command and keeps its reply whatever the answer.
///
/// Unlike `scriptRunCommand`, this does not check for a cancel once the server has answered: the
/// write has happened by then, and throwing its reply away would lose the only record of what it
/// changed. The host's cancel latch refuses the script's next call instead.
func scriptWriteCommand(client: OpaquePointer, command: String, database: String?) throws -> MongoWriteOutcome {
let result = try sendCommand(client: client, command: command, database: database)
let failure = MongoWriteFailure.read(fromReply: result.reply)
guard !result.ok, failure == nil else {
return MongoWriteOutcome(replyJson: result.reply, failure: failure)
}
return MongoWriteOutcome(replyJson: result.reply, failure: unansweredFailure(result.error))
}

/// The write concern the connection's URI sets, as the `writeConcern` document a command takes,
/// or nil when it sets none and the server's default applies.
///
/// Read from libmongoc rather than from the connection's setting, so `journal` and
/// `wtimeoutMS` from an imported connection string travel with `w`.
func writeConcernJson(client: OpaquePointer) -> String? {
guard let concern = mongoc_client_get_write_concern(client),
!mongoc_write_concern_is_default(concern),
let copy = mongoc_write_concern_copy(concern) else { return nil }
defer { mongoc_write_concern_destroy(copy) }
let document = bson_new()
defer { bson_destroy(document) }
guard mongoc_write_concern_append(copy, document), let json = bsonToJson(document) else { return nil }
return MongoScriptJson.member(of: json, key: "writeConcern")
}

private func sendCommand(
client: OpaquePointer, command: String, database: String?
) throws -> (ok: Bool, reply: String, error: bson_error_t) {
try checkCancelled()

guard let bsonCommand = jsonToBson(command) else {
Expand All @@ -52,10 +91,18 @@ extension MongoDBConnection {
let ok = resolved.withCString {
mongoc_client_command_simple(client, $0, bsonCommand, nil, reply, &error)
}
return (ok, bsonToJson(reply) ?? "{}", error)
}

try checkCancelled()
guard ok else { throw makeError(error) }
return bsonToJson(reply) ?? "{}"
/// A failed call whose reply carries no server answer. A stream or protocol error means the
/// command may have reached the server before the connection broke; anything else stopped it
/// on this side.
private func unansweredFailure(_ error: bson_error_t) -> MongoWriteFailure {
let reported = makeError(error)
let reachedServer = error.domain == MONGOC_ERROR_STREAM.rawValue || error.domain == MONGOC_ERROR_PROTOCOL.rawValue
return MongoWriteFailure(
code: reported.code, message: reported.message, stage: reachedServer ? .unanswered : .notSent
)
}

func scriptFind(
Expand Down Expand Up @@ -139,14 +186,20 @@ extension MongoDBConnection {
/// A document with no `_id` gets one prepended through libbson rather than through a Swift
/// dictionary, so the rest of its fields keep the order the script wrote them in and `_id`
/// lands first, where the server puts it.
///
/// A failed insert still returns: an ordered insert keeps every document before the one that
/// failed, and the reply's `insertedCount` is the only record of how many.
func scriptInsert(
client: OpaquePointer,
database: String,
collection: String,
documents: [String]
) throws -> [String] {
documents: [String],
options: String?
) throws -> (identifiers: [String], outcome: MongoWriteOutcome) {
try checkCancelled()
guard !documents.isEmpty else { return [] }
guard !documents.isEmpty else {
return ([], MongoWriteOutcome(replyJson: "{}", failure: nil))
}

let handle = try getCollection(client, database: database, collection: collection)
defer { mongoc_collection_destroy(handle) }
Expand Down Expand Up @@ -174,6 +227,14 @@ extension MongoDBConnection {
identifiers.append("{\"$oid\": \"\(hex)\"}")
}

let optsBson = try options.map { json -> OpaquePointer in
guard let parsed = jsonToBson(json) else {
throw MongoDBError(code: 0, message: MongoScriptText.invalidDocument(json))
}
return parsed
}
defer { if let optsBson { bson_destroy(optsBson) } }

try checkCancelled()

var pointers: [OpaquePointer?] = prepared.map { Optional($0) }
Expand All @@ -183,10 +244,26 @@ extension MongoDBConnection {

let ok = pointers.withUnsafeMutableBufferPointer { buffer -> Bool in
guard let base = buffer.baseAddress else { return false }
return mongoc_collection_insert_many(handle, base, buffer.count, nil, reply, &error)
return mongoc_collection_insert_many(handle, base, buffer.count, optsBson, reply, &error)
}
let replyJson = bsonToJson(reply) ?? "{}"
guard !ok else {
return (identifiers, MongoWriteOutcome(replyJson: replyJson, failure: nil))
}
guard ok else { throw makeError(error) }
return identifiers
let failure = MongoWriteFailure.read(fromReply: replyJson) ?? insertFailure(error)
return (identifiers, MongoWriteOutcome(replyJson: replyJson, failure: failure))
}

/// An insert that failed with no server answer.
///
/// libmongoc 1.28.1 sends a large insert in batches, and clears its error when the last batch
/// it sent succeeded. An insert that stops at a document over the server's size limit after a
/// batch already went therefore fails with no error at all, while its reply counts that batch.
private func insertFailure(_ error: bson_error_t) -> MongoWriteFailure {
guard error.domain == 0, error.code == 0 else { return unansweredFailure(error) }
return MongoWriteFailure(
code: 0, message: MongoScriptText.insertStoppedAtOversizedDocument, stage: .stoppedBetweenBatches
)
}

func listIndexesJsonSync(
Expand Down
58 changes: 38 additions & 20 deletions Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -223,11 +223,8 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
),
rowCap: rowCap
)
} catch let failure as MongoScriptStatementFailure {
currentDb = failure.databaseSwitch
throw mapExecutionError(failure.underlying)
} catch {
throw mapExecutionError(error)
throw reportedError(error)
}
}

Expand Down Expand Up @@ -275,16 +272,32 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
)
}

private func mapExecutionError(_ error: Error) -> Error {
guard let mongoError = error as? MongoDBError,
MongoDBTimeoutPolicy.isTimeoutCode(mongoError.code),
let maxTimeMS = mongoConnection?.effectiveMaxTimeMS(background: false) else {
return error
/// The error a failed statement surfaces, built in one place so the timeout wording and the
/// note about documents already written cannot overwrite each other.
///
/// A cancel stays a cancel even when the statement had written: the app discards the result of
/// a query the user stopped, so there is nothing to show the note on. A write's failure leaves
/// as a `MongoDBError` like every other, so the app reads its code the same way.
private func reportedError(_ error: Error) -> Error {
var underlying = error
var writes = MongoWriteLedger()
if let failure = error as? MongoScriptStatementFailure {
if let switched = failure.databaseSwitch { currentDb = switched }
underlying = failure.underlying
writes = failure.writes
}
return MongoDBError(
code: mongoError.code,
message: MongoDBTimeoutPolicy.timeoutMessage(maxTimeMS: maxTimeMS)
if underlying is CancellationError { return underlying }
let failedWrite = underlying as? MongoWriteFailure
let code = failedWrite?.code ?? (underlying as? MongoDBError)?.code ?? 0
let message = failedWrite?.message ?? (underlying as? MongoDBError)?.message ?? underlying.localizedDescription
let reported = writes.reportedMessage(
code: code,
message: message,
failedWrite: failedWrite?.stage,
maxTimeMS: mongoConnection?.effectiveMaxTimeMS(background: false)
)
guard reported != message || failedWrite != nil else { return underlying }
return MongoDBError(code: code, message: reported)
}

// MARK: - Query Cancellation
Expand Down Expand Up @@ -838,7 +851,8 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
let work = Task {
do {
switch try await runtime.exportPlan(for: trimmed, database: db) {
case .cursor(let plan):
case .cursor(let plan, let databaseSwitch, let writes):
if let databaseSwitch { self.currentDb = databaseSwitch }
let inner = plan.isFind
? conn.streamFind(
database: plan.database, collection: plan.collection,
Expand All @@ -852,19 +866,23 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
pipeline: plan.pipeline,
optionsJson: plan.options.aggregateOptionsJson(timeoutMS: timeout)
)
for try await element in inner {
try Task.checkCancellation()
continuation.yield(element)
do {
for try await element in inner {
try Task.checkCancellation()
continuation.yield(element)
}
} catch {
throw MongoScriptStatementFailure.carrying(
error, databaseSwitch: databaseSwitch, writes: writes
)
}
case .result(let outcome):
if let switched = outcome.databaseSwitch { self.currentDb = switched }
self.yieldMaterialised(outcome, into: continuation)
}
continuation.finish()
} catch let failure as MongoScriptStatementFailure {
self.currentDb = failure.databaseSwitch
continuation.finish(throwing: failure.underlying)
} catch {
continuation.finish(throwing: error)
continuation.finish(throwing: self.reportedError(error))
}
}
// A consumer that stops reading has to stop the cursor too, or it keeps draining the
Expand Down
27 changes: 24 additions & 3 deletions Plugins/MongoDBDriverPlugin/MongoDBTimeoutPolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ enum MongoDBServerErrorCode {
static let indexNotFound: UInt32 = 27
static let maxTimeMSExpired: UInt32 = 50
static let cursorKilled: UInt32 = 237

/// Every code in the server's `Interruption` category on each release branch from 4.0 through
/// 9.0, read from its `src/mongo/base/error_codes.yml` (`error_codes.err` before 4.4). The
/// server fails a whole write batch with `ok: 0` for these, keeping the documents it already
/// wrote. `scripts/check-mongodb-interruption-codes.sh` diffs the set against every branch.
static let interruptionCategory: Set<UInt32> = [
24, maxTimeMSExpired, cursorKilled, 262, 279, 281, 282, 290, 355, 453, 471, 473, 485, 509,
11_600, 11_601, 11_602, 46_841, 91_331, 10_045_600
]
}

enum MongoDBTimeoutPolicy {
Expand All @@ -34,16 +43,28 @@ enum MongoDBTimeoutPolicy {
}

static func timeoutMessage(maxTimeMS: Int32) -> String {
let seconds = max(1, Int((Double(maxTimeMS) / 1_000).rounded()))
return String(
String(
format: String(
localized: """
The query timed out after %d seconds. Sorting or filtering on a field with no index \
makes MongoDB read the whole collection, even when you only ask for one page. \
Add an index for that field, or raise the query timeout in Settings.
"""
),
seconds
seconds(maxTimeMS)
)
}

static func writeTimeoutMessage(maxTimeMS: Int32) -> String {
String(
format: String(
localized: "The write did not finish within %d seconds, so MongoDB stopped it. Raise the query timeout in Settings if it needs longer."
),
seconds(maxTimeMS)
)
}

private static func seconds(_ maxTimeMS: Int32) -> Int {
max(1, Int((Double(maxTimeMS) / 1_000).rounded()))
}
}
Loading
Loading