Handle custom types with differing OIDs across shards - #1280
Conversation
|
This is now a working implementation. It needs some cleanup and backfill of unit tests, but should be able to make it into tomorrow's release |
| /// A combination of `SetOnce` and `OnceCell`. | ||
| /// It allows only a single writer to run, with no contention on future reads, | ||
| /// while also allowing callers to wait on the value. | ||
| pub(crate) struct SetOnceCell<T> { |
There was a problem hiding this comment.
Prior art: tokio-rs/tokio#4788 https://doc.rust-lang.org/stable/std/sync/struct.OnceLock.html
(Likely going to try to upstream this to tokio since it was previously rejected as a new feature on OnceCell, but it was mentioned it may be accepted as a new type)
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
|
Between making sure we have adequate test coverage for |
At the wire protocol level, type information is entirely handled by OID, not type name. An OID is a unique identifier that PG assigns for virtually every schema related entity. They are extremely dependent on the order that plugins are loaded, DDL is run, etc. Because of this it's quite likely that a user or extension defined type will not have the same OID across multiple shards. To solve this, we need to do three things: - Ensure clients receive a single canonical answer when they query for type information - Rewrite any messages being sent to the server to ensure they have the correct OIDs for a given type for that sever - Rewrite any messages being sent to the client to ensure they have the canonical OIDs for a given type This commit's solution to ensuring the client gets canoincal type information is straightforward, but not ideal. We look for any select statements that could be loading type information (referencing the relevant tables in `pg_catalog`, or doing certain casts), and route them to shard 0. This means that we have a single point of failure for schema information. Ideally we would be responding to these queries ourselves rather than sending them to the server, but the breadth of different ways different client libraries will load this type information would mean we essentially have to implement our own shitty SQL server. It's possible, and probably necessary in the long run, but outside of the scope of this specific fix. It's not clear that there'd be any reason one shard would be better than another for the canonical information, but if there is one, we could probably surface this as a configuration option. But since there's no clear reason for it, we can just do the reasonable default. For rewriting messages, we need to keep track of the mappings for each shard on our end. This was actually somewhat tricky to make work, as schema loading happens on `Shard`, but the message rewriting needs to happen all the way down in `Server`, and these are not types that share an API bounary. To rectify this, we use a new primitive, which is the async equivalent of `std::sync::OnceLock`. It ensures that only one writer will ever attempt to write to it, while also allowing waiters to wait for a value to be set without attempting to initialize themselves. With this primitive, all we need to do is stick it in an `Arc` to have it shared in as many disparate places as we need. We use the `SchemaCache` that was previously introduced to ensure we only have one copy of this mapping per database, regardless of how many user/clusters we have, but its use is relatively minimal. Since this sits in a very hot path, I've done as much as I could to avoid excess allocations or loops if they're not strictly necessary. I'm assuming that the majority of users won't have custom types that vary between shards, so this change should ideally be free for those users, short of the query to determine that there are no mappings at startup. Tests were a little bit trickier. Resolving the OIDs happens at cluster launch, and the code that needs to do the rewriting will never run without the cluster launching and loading first. But many of our tests were constructing clusters without launching them. I worked around this by making `Oids::default` return an instance that is already resolved to an empty set of mappings. Since the normal construction requires canonical type information to be passed in, we don't need to worry about production code accidentally calling this incorrectly.
Not sure why I got these mixed up in my head, regtype casts are what is equivalent to selecting from `pg_type`
This isn't ideal. We can't just say "we don't perform OID mapping for direct-to-shard transactions", as type information (including OIDs) is typically cached client-side for the entire lifetime of the connection, and that same conneciton could be used to hit a different shard in the very next transaction. If we supported lazily connecting to shards as we need them, then we could make an exception for schema based sharding or transactions routed based on SET specifically to route schema queries to shard 0. But for now the best we can do is warn, and hope that the query isn't looking for any OIDs that will differ across shards. This warning should rarely fire, if ever. Queries to `pg_type` are typically handled by the client library, not the user, and they are typically run immediately upon connecting. For those queries to be in a direct-to-shard transaction they would need to be run in a transaction where search_path or pg_shard are immediately set. The test that hit this issue was a migration library, which I suspect is the only client pattern that could hit this. And since DDL shouldn't be affected by OID information, I don't believe this will end up causing any trouble for those who do receive the warning
…hard" This reverts commit a62bb2c. When I wrote my reasoning in that commit I didn't realize that this was only happening because of the way we are monkey patching alembic. I assume this is based on a pattern used by a customer, the real solution is likely just to have this be config option that can be disabled in that case
I opted to concatenate strings instead of selecting two columns to avoid the PITA of borrowing a tuple key for a hash map lookup
Previously we were rewriting them when being inserted into the global cache, which only occurs in the extended protocol. We now always attempt to rewrite them, regardless of whether they are going into a cache or not. This introduces a little bit of trickiness. First, we expect the most common case to be that there are no mappings at all. When using the simple protocol, we previously were never parsing `RowDescription` messages. If the OID map is empty, we want to continue to avoid performing that parsing. The second bit of trickiness is that this rewrite will now be called as part of loading the OIDs themselves. So we can no longer expect them to always be set for this function in particular, and need to return an empty set instead.
I had hoped not to silently ignore OIDs not being loaded, as panicking could potentially catch bugs in the future. But now that we have legitimate reasons that we need to allow OIDs to not be loaded, this panic is no longer worth the cost to the rest of the code base
This turns off the canonicalization behavior by default. We expect this feature to only be useful for a subset of our users, and there are certain usage patterns that cannot support OID canonicalization at all (as demonstrated in `integration/python/albemic/test_migration.py`). As such, this behavior is disabled by default, and users who are using sharding and have extension or user defined types can enable the feature explicitly.
levkk
left a comment
There was a problem hiding this comment.
LGTM!
Might be worth considering using fnv (already in Cargo.toml) instead of hashbrown for mappings. It's faster and also we don't need dos protection since oids are database-generated.
Bonus points: include the code our friends gave us as repro into our Ruby test suite to make sure it works as expected and doesn't break in the future.
In theory this could deadlock if the pool size were 1 and the first attempt to load the canonical OIDs failed for some reason
This caused an obscure lifetime related compiler error that appears to come from deep within the async/await plumbing that I can't make sense of
Handle custom types with differing OIDs across shards
At the wire protocol level, type information is entirely handled by OID, not type name. An OID is a unique identifier that PG assigns for virtually every schema related entity. They are extremely dependent on the order that plugins are loaded, DDL is run, etc. Because of this it's quite likely that a user or extension defined type will not have the same OID across multiple shards.
To solve this, we need to do three things:
type information
correct OIDs for a given type for that sever
canonical OIDs for a given type
This commit's solution to ensuring the client gets canoincal type information is straightforward, but not ideal. We look for any select statements that could be loading type information (referencing the relevant tables in
pg_catalog, or doing certain casts), and route them to shard 0.This means that we have a single point of failure for schema information. Ideally we would be responding to these queries ourselves rather than sending them to the server, but the breadth of different ways different client libraries will load this type information would mean we essentially have to implement our own shitty SQL server. It's possible, and probably necessary in the long run, but outside of the scope of this specific fix.
It's not clear that there'd be any reason one shard would be better than another for the canonical information, but if there is one, we could probably surface this as a configuration option. But since there's no clear reason for it, we can just do the reasonable default.
For rewriting messages, we need to keep track of the mappings for each shard on our end. This was actually somewhat tricky to make work, as schema loading happens on
Shard, but the message rewriting needs to happen all the way down inServer, and these are not types that share an API bounary.To rectify this, we use a new primitive, which is the async equivalent of
std::sync::OnceLock. It ensures that only one writer will ever attempt to write to it, while also allowing waiters to wait for a value to be set without attempting to initialize themselves.With this primitive, all we need to do is stick it in an
Arcto have it shared in as many disparate places as we need. We use theSchemaCachethat was previously introduced to ensure we only have one copy of this mapping per database, regardless of how many user/clusters we have, but its use is relatively minimal.Since this sits in a very hot path, I've done as much as I could to avoid excess allocations or loops if they're not strictly necessary. I'm assuming that the majority of users won't have custom types that vary between shards, so this change should ideally be free for those users, short of the query to determine that there are no mappings at startup.
Tests were a little bit trickier. Resolving the OIDs happens at cluster launch, and the code that needs to do the rewriting will never run without the cluster launching and loading first. But many of our tests were constructing clusters without launching them.
I worked around this by making
Oids::defaultreturn an instance that is already resolved to an empty set of mappings. Since the normal construction requires canonical type information to be passed in, we don't need to worry about production code accidentally calling this incorrectly.