diff --git a/Package.resolved b/Package.resolved index a44b7e2..babac96 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "ea3b79b78be18e85af3a26baa51616c380e4cd43f77304dd1688f0bcc6cbe204", + "originHash" : "27bb6664d476306e8f9d8e8d349100d725e33f2fbb8059fd08732ada339d15eb", "pins" : [ { "identity" : "feather-database", "kind" : "remoteSourceControl", "location" : "https://github.com/feather-framework/feather-database", "state" : { - "revision" : "34b05e9ca725bf857c9bc6e29603a4e457f9969a", - "version" : "1.0.0-beta.1" + "revision" : "147c96803f398c50f5717ce08ebd78a9e3ab7ca7", + "version" : "1.0.0-beta.2" } }, { @@ -67,10 +67,10 @@ { "identity" : "swift-log", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-log.git", + "location" : "https://github.com/apple/swift-log", "state" : { - "revision" : "7ee16e465622412764b0ff0c1301801dc71b8f61", - "version" : "1.9.0" + "revision" : "2778fd4e5a12a8aaa30a3ee8285f4ce54c5f3181", + "version" : "1.9.1" } }, { @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "4a9a97111099376854a7f8f0f9f88b9d61f52eff", - "version" : "2.92.2" + "revision" : "233f61bc2cfbb22d0edeb2594da27a20d2ce514e", + "version" : "2.93.0" } }, { diff --git a/Package.swift b/Package.swift index 416bba3..5a252eb 100644 --- a/Package.swift +++ b/Package.swift @@ -37,7 +37,8 @@ let package = Package( dependencies: [ .package(url: "https://github.com/apple/swift-log", from: "1.6.0"), .package(url: "https://github.com/vapor/postgres-nio", from: "1.27.0"), - .package(url: "https://github.com/feather-framework/feather-database", exact: "1.0.0-beta.1"), + .package(url: "https://github.com/feather-framework/feather-database", exact: "1.0.0-beta.2"), + // [docc-plugin-placeholder] ], targets: [ .target( diff --git a/README.md b/README.md index 6492c41..d087ec5 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,11 @@ Postgres driver implementation for the abstract [Feather Database](https://github.com/feather-framework/feather-database) Swift API package. -![Release: 1.0.0-beta.1](https://img.shields.io/badge/Release-1%2E0%2E0--beta%2E1-F05138) +[ + ![Release: 1.0.0-beta.2](https://img.shields.io/badge/Release-1%2E0%2E0--beta%2E2-F05138) +]( + https://github.com/feather-framework/feather-postgres-database/releases/tag/1.0.0-beta.2 +) ## Features @@ -33,7 +37,7 @@ Postgres driver implementation for the abstract [Feather Database](https://githu Add the dependency to your `Package.swift`: ```swift -.package(url: "https://github.com/feather-framework/feather-postgres-database", exact: "1.0.0-beta.1"), +.package(url: "https://github.com/feather-framework/feather-postgres-database", exact: "1.0.0-beta.2"), ``` Then add `FeatherPostgresDatabase` to your target dependencies: @@ -45,7 +49,11 @@ Then add `FeatherPostgresDatabase` to your target dependencies: ## Usage -![DocC API documentation](https://img.shields.io/badge/DocC-API_documentation-F05138) +[ + ![DocC API documentation](https://img.shields.io/badge/DocC-API_documentation-F05138) +]( + https://feather-framework.github.io/feather-postgres-database/documentation/featherpostgresdatabase/ +) API documentation is available at the following link. @@ -127,7 +135,7 @@ The following database driver implementations are available for use: - Build: `swift build` - Test: - local: `swift test` - - using Docker: `swift docker-test` + - using Docker: `make docker-test` - Format: `make format` - Check: `make check` diff --git a/Sources/FeatherPostgresDatabase/PostgresDatabaseClient.swift b/Sources/FeatherPostgresDatabase/PostgresDatabaseClient.swift index bfd7645..cee9781 100644 --- a/Sources/FeatherPostgresDatabase/PostgresDatabaseClient.swift +++ b/Sources/FeatherPostgresDatabase/PostgresDatabaseClient.swift @@ -47,11 +47,10 @@ public struct PostgresDatabaseClient: DatabaseClient { /// - Throws: A `DatabaseError` if connection handling fails. /// - Returns: The query result produced by the closure. @discardableResult - public func connection( + public func connection( isolation: isolated (any Actor)? = #isolation, - _ closure: (PostgresConnection) async throws -> - sending PostgresQueryResult, - ) async throws(DatabaseError) -> sending PostgresQueryResult { + _ closure: (PostgresConnection) async throws -> sending T, + ) async throws(DatabaseError) -> sending T { do { return try await client.withConnection(closure) } @@ -72,12 +71,10 @@ public struct PostgresDatabaseClient: DatabaseClient { /// - Throws: A `DatabaseError` if the transaction fails. /// - Returns: The query result produced by the closure. @discardableResult - public func transaction( + public func transaction( isolation: isolated (any Actor)? = #isolation, - _ closure: ( - (PostgresConnection) async throws -> sending PostgresQueryResult - ), - ) async throws(DatabaseError) -> sending PostgresQueryResult { + _ closure: ((PostgresConnection) async throws -> sending T), + ) async throws(DatabaseError) -> sending T { do { return try await client.withTransaction( logger: logger, diff --git a/Tests/FeatherPostgresDatabaseTests/FeatherPostgresDatabaseTestSuite.swift b/Tests/FeatherPostgresDatabaseTests/FeatherPostgresDatabaseTestSuite.swift index 07e1692..8c53f60 100644 --- a/Tests/FeatherPostgresDatabaseTests/FeatherPostgresDatabaseTestSuite.swift +++ b/Tests/FeatherPostgresDatabaseTests/FeatherPostgresDatabaseTestSuite.swift @@ -745,6 +745,150 @@ struct FeatherPostgresDatabaseTestSuite { } } + @Test + func concurrentTransactionUpdates() async throws { + try await runUsingTestDatabaseClient { database in + let suffix = randomTableSuffix() + let table = "sessions_\(suffix)" + let sessionID = "session_\(suffix)" + + enum TestError: Error { + case missingRow + } + + try await database.execute( + query: #""" + DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; + """# + ) + try await database.execute( + query: #""" + CREATE TABLE "\#(unescaped: table)" ( + "id" TEXT NOT NULL PRIMARY KEY, + "access_token" TEXT NOT NULL, + "access_expires_at" TIMESTAMPTZ NOT NULL, + "refresh_token" TEXT NOT NULL, + "refresh_count" INTEGER NOT NULL DEFAULT 0 + ); + """# + ) + + // set an expired token + try await database.execute( + query: #""" + INSERT INTO "\#(unescaped: table)" + ("id", "access_token", "access_expires_at", "refresh_token", "refresh_count") + VALUES + ( + \#(sessionID), + 'stale', + NOW() - INTERVAL '5 minutes', + 'refresh', + 0 + ); + """# + ) + + func getValidAccessToken(sessionID: String) async throws -> String { + try await database.transaction { connection in + let result = try await connection.execute( + query: #""" + SELECT + "access_token", + "refresh_count", + "access_expires_at" > NOW() + INTERVAL '60 seconds' AS "is_valid" + FROM "\#(unescaped: table)" + WHERE "id" = \#(sessionID) + FOR UPDATE; + """# + ) + let rows = try await result.collect() + + guard let row = rows.first else { + throw TestError.missingRow + } + + let isValid = try row.decode( + column: "is_valid", + as: Bool.self + ) + if isValid { + // token was valid, must be called X times + return try row.decode( + column: "access_token", + as: String.self + ) + } + + // refresh, this branch can only be called 1 time + let refreshCount = try row.decode( + column: "refresh_count", + as: Int.self + ) + let newRefreshCount = refreshCount + 1 + let newToken = "token_\(newRefreshCount)" + + try await Task.sleep(for: .milliseconds(40)) + + _ = try await connection.execute( + query: #""" + UPDATE "\#(unescaped: table)" + SET + "access_token" = \#(newToken), + "access_expires_at" = NOW() + INTERVAL '10 minutes', + "refresh_count" = \#(newRefreshCount) + WHERE "id" = \#(sessionID); + """# + ) + + return newToken + } + } + + let workerCount = 80 + var tokens: [String] = [] + try await withThrowingTaskGroup(of: String.self) { group in + for _ in 0.. NOW() AS "is_valid" + FROM "\#(unescaped: table)" + WHERE "id" = \#(sessionID); + """# + ) + .collect() + + #expect(result.count == 1) + #expect( + try result[0].decode(column: "refresh_count", as: Int.self) + == 1 + ) + #expect( + try result[0].decode(column: "access_token", as: String.self) + == "token_1" + ) + #expect( + try result[0].decode(column: "is_valid", as: Bool.self) + == true + ) + } + } + @Test func doubleRoundTrip() async throws { try await runUsingTestDatabaseClient { database in