I want to touch on something tangentially related to the Generic Connection Pool post I made recently. It's been discussed a lot amongst the SSWG and Vapor community and I wanted to put my thoughts down and explain Vapor's approach for the record. I'm not sure if it has a better name, but I'm calling it "the context passing problem".
This problem exists in a lot of situations, but for the purpose of this post I want to highlight connection pooling. When grabbing a connection from a pool, you will probably want the connection to log somewhere else. For example, in Vapor, each request has its own logger with a unique request identifier. When a connection is pulled from the pool, it is vital that this logger be used so that the logs can be correctly identified later.
Explicit context passing
The most straightforward way to implement this is to have all methods on the connection accept an "override" logger. This is the method AsyncHTTPClient employs. Let's call this explicit context passing For example:
// This connection will be using whatever logger it
// was created with.
let connection = try pool.requestConnection().wait()
defer { pool.releaseConnection(connection)
// However, we want it to log to this special logger.
// Using AHC-style, we would just pass this in.
try pool.doThing(logger: customLogger).wait()
This method works great, but the obvious downside is that the developer must remember to pass the logger in. This problem becomes worse if you need to pass additional context, like BaggageContext
for tracing. Imagine this API in a high-level Vapor app:
app.get("users") { req in
User.query(
on: req.db,
logger: req.logger,
baggageContext: req.baggageContext
).all()
}
Example usages:
- AsyncHTTPClient: async-http-client/HTTPClient.swift at main · swift-server/async-http-client · GitHub
Protocol-based context passing
To avoid this, Vapor implements protocols similar to the following. Let's call this protocol-based context passing.
protocol Database {
var eventLoop: EventLoop { get }
var logger: Logger { get }
var baggageContext: BaggageContext { get }
}
extension Database {
func delegating(to eventLoop: EventLoop) -> Database
func logging(to logger: Logger) -> Database
func tracing(to baggageContext: BaggageContext) -> Database
}
A real example of this can be seen in PostgresNIO's PostgresDatabase
protocol.
The protocol extensions simply wrap the type in a struct that temporarily overrides that property and implements the provided guarantees. This is thread-safe but does add indirection.
Vapor uses these protocols and their extensions to implement req.db
in a way that all of the required context is always available, without the developer needing to pass things manually. So the high level API looks more like this:
app.get("users") { req in
User.query(on: req.db).all()
}
Example usages:
- PostgresNIO: postgres-nio/PostgresDatabase.swift at main · vapor/postgres-nio · GitHub
- MySQLNIO: mysql-nio/MySQLDatabase.swift at main · vapor/mysql-nio · GitHub
- SQLiteNIO: sqlite-nio/SQLiteConnection.swift at main · vapor/sqlite-nio · GitHub
- APNSwift: https://github.com/kylebrowning/APNSwift/blob/master/Sources/APNSwift/APNSwiftClient.swift
Impact on high-level frameworks
Being able to hide context passing like this is critical to create nice, high-level APIs. Having this built into libraries like PostgresNIO natively is very important for Vapor. When libraries do not offer these types of protocols, Vapor must wrap them. AsyncHTTPClient for example uses the explicit-passing method and thus Vapor wraps its interface behind a Client
protocol.
Wrapping AsyncHTTPClient is by no means the end of the world, in fact some times wrapping is the better option anyway to make APIs feel more comfortable in the framework. However, there are some situations where this required wrapping can create a lot of unnecessary code duplication.
The main example that has come up recently is RediStack. This package currently offers a protocol similar to the Database
protocol declared above: RedisClient
. However, instead of implementing a logging(to:)
method, it uses setLogging(to:)
which is not thread-safe. Because of this, one of the recommendations has been to move to AHC-style "explicit passing": [Thread Safety] - The setLogging(to:) method is not thread safe (#79) · Issues · Nathan Harris / RediStack · GitLab
This change would by no means prevent Vapor from using RediStack, but it would mean that we would need to wrap it in a protocol similar to how we use AHC. This is rather unfortunate since, unlike AHC, RediStack offers a large number of wonderful convenience methods: Sources/RediStack/Commands · master · Nathan Harris / RediStack · GitLab
By wrapping RedisClient
in a new protocol, Vapor hides access to its methods and must re-implement them in its public API. Not only does this create duplicated work and more source for bugs, it also means that as new methods are added to RediStack, they will not be immediately available to users of Vapor.
By using the protocol-based context passing approach instead, RediStack would allow its RedisClient
protocol to be used directly in high-level frameworks like Vapor.
I'm sure this issue will come up in many more places as new server-side Swift packages are created.
I'm very interested to hear everyone's thoughts on this. Thanks!