fluent-kit icon indicating copy to clipboard operation
fluent-kit copied to clipboard

Transaction isolation levels

Open calebkleveter opened this issue 5 years ago • 12 comments

In MySQL and PostgreSQL, you can set the isolation level of your transactions globally or by session. The isolation level basically says how the data from the transaction queries is handled. For example, I can set the level to READ UNCOMMITTED, so a SELECT operation will retrieve data that has been added using INSERT or UPDATE even before the transaction is committed.

I propose we have DatabaseConnectable.transaction method that takes in the isolation level for the transaction that will be run. A type-specific MySQL implementation that I created looks like this:

import MySQL

extension MySQLDatabase {
    enum IsolationLevel: String {
        case repeatable = "REPEATABLE READ"
        case committed = "READ COMMITTED"
        case uncommitted = "READ UNCOMMITTED"
        case serializable = "SERIALIZABLE"
        
        static let `default`: IsolationLevel = .repeatable
        static let sessionColumn: String = "@@global.transaction_isolation"
        
        static func parse(_ level: String) -> IsolationLevel? {
            switch level {
            case "REPEATABLE-READ": return .repeatable
            case "READ-COMMITTED": return .committed
            case "READ-UNCOMMITTED": return .uncommitted
            case "SERIALIZABLE": return .serializable
            default: return nil
            }
        }
        
        static func current(from conn: MySQLConnection) -> EventLoopFuture<MySQLDatabase.IsolationLevel> {
            let sessionColumn = MySQLDatabase.IsolationLevel.sessionColumn
            return conn.simpleQuery("SELECT " + sessionColumn).map { data in
                let raw = data.first?[.init(name: sessionColumn)]?.string()
                
                guard let string = raw, let level = MySQLDatabase.IsolationLevel.parse(string) else {
                    throw MySQLError(
                        identifier: "unknowIsolationLevel",
                        reason: "Received unknown MySQL isolation level `\(raw ?? "NULL")` from database"
                    )
                }
                
                return level
            }
        }
        
        var query: String {
            return "SET GLOBAL TRANSACTION ISOLATION LEVEL " + self.rawValue
        }
    }
}

extension DatabaseConnectable {
    func transaction<T>(
        on db: DatabaseIdentifier<MySQLDatabase>,
        level: MySQLDatabase.IsolationLevel,
        _ closure: @escaping (MySQLConnection)throws -> Future<T>
    ) -> Future<T>
    {
        return databaseConnection(to: db).flatMap { conn in
            return MySQLDatabase.IsolationLevel.current(from: conn).flatMap { sessionLevel in
                return conn.simpleQuery(level.query).transform(to: sessionLevel)
            }.flatMap { sessionLevel in
                return conn.simpleQuery("START TRANSACTION").transform(to: sessionLevel)
            }.flatMap { sessionLevel in
                return try closure(conn).flatMap { result in
                    return conn.simpleQuery("COMMIT").transform(to: result)
                }.flatMap { result in
                    return conn.simpleQuery(sessionLevel.query).transform(to: result)
                }.catchFlatMap { error in
                    return conn.simpleQuery("ROLLBACK").flatMap { _ in
                        return conn.simpleQuery(sessionLevel.query)
                    }.map { _ in
                        throw error
                    }
                }
            }
        }
    }
}

It could probably be better, but it's a start. To call it, you just add the level parameter to the normal .transaction call:

connection.transaction(.mysql, level: .uncommitted) { transaction in
    // Do stuff here...
}

calebkleveter avatar Mar 11 '19 19:03 calebkleveter

Is there any way to set the isolation level for a given transaction without setting it globally? Given that connections are re-used in Fluent, I don't think it would be a good idea to set it globally.

tanner0101 avatar Mar 12 '19 17:03 tanner0101

I tired to do that. In theory you should be able to to set it without the GLOBAL keyword:

SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED

But for some reason that didn't seem to work, as though I had never set the isolation level at all. This might need more research.

calebkleveter avatar Mar 12 '19 17:03 calebkleveter

+1 for this!

jdmcd avatar Aug 20 '20 17:08 jdmcd

@0xTim what do you think the effort level would be like to get this added to Fluent? Right now we have a case where a nightly long-running transaction kicked off by a client is blocking access to the site because it's locking up a bunch of tables that get called frequently. We (Transeo) would be happy to contribute dev effort towards this if we got pointed in the right direction.

jdmcd avatar May 20 '21 00:05 jdmcd

Honestly I'm not sure, it's outside my SQL knowledge. @gwynne any idea? From the looks of @calebkleveter 's implementation it should be fairly straight forward, we'd just need to add it to MySQL and Postgres as they'll be the only ones that support it I think

0xTim avatar May 20 '21 09:05 0xTim

bump @gwynne

0xTim avatar Aug 12 '21 12:08 0xTim

Sorry for the delay in replying! To answer @jdmcd's question: Getting this added at the FluentKit/SQLKit layers is actually pretty straightforward, just need an extra property in SQLDialect and to update the drivers that do provide isolation level support, which is just MySQL and Postgres (arguably SQLite's BEGIN EXCLUSIVE TRANSACTION can be considered an isolation level as well, but that's probably overdoing it since it'd require a whole separate set of syntax support, as with the mess that is enum handling). I could probably do the work in an hour or two if I put my mind to it.

gwynne avatar Aug 19 '21 08:08 gwynne

@gwynne did you happen to have any update on this? It's a feature that I'd find very useful.

ptrkstr avatar Nov 24 '21 11:11 ptrkstr

I tired to do that. In theory you should be able to to set it without the GLOBAL keyword:

SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED

But for some reason that didn't seem to work, as though I had never set the isolation level at all. This might need more research.

@calebkleveter did you initiate the transaction first? According to this, if you don't do that, the isolation level won't be considered.

ptrkstr avatar Nov 24 '21 13:11 ptrkstr

The most common issues when trying to do this with Fluent are:

  1. For MySQL users, SET TRANSACTION ISOLATION LEVEL can not be issued through the full query interface that FluentKit and SQLKit use, you have to drop down to MySQLKit-level API to use the simpleQuery command.
  2. More globally, the isolation level setting is per-connection, and unless explicitly told not to, Fluent can send any given query on any connection it has to the database on the same event loop, including opening a new one if all existing connections are considered busy at the time. The only ways around this are to either use the Database.withConnection(_:) API to request a specific connection to issue the isolation level query on and then call Database.transaction(_:) on that connection, or to set maxConnectionsPerEventLoop to 1 so there will only ever be one connection on any given event loop (which is almost certainly not what you want).

The API I have in mind for providing transaction isolation level support will take care of both of these problems transparently.

gwynne avatar Nov 24 '21 14:11 gwynne

Thanks for continuing to look into it @gwynne. 🙏🙏🙏🙏🙏

ptrkstr avatar Nov 24 '21 16:11 ptrkstr

@gwynne any update on this?

anonymouz4 avatar Mar 19 '22 16:03 anonymouz4