Skip to content

Handle custom types with differing OIDs across shards - #1280

Open
sgrif wants to merge 21 commits into
mainfrom
sg-oid-drift-2
Open

Handle custom types with differing OIDs across shards#1280
sgrif wants to merge 21 commits into
mainfrom
sg-oid-drift-2

Conversation

@sgrif

@sgrif sgrif commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

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:

  • 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.

@sgrif
sgrif requested a review from levkk July 28, 2026 22:40
@sgrif

sgrif commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

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

@sgrif
sgrif requested a review from meskill July 29, 2026 21:49
Comment thread pgdog/src/sync.rs
/// 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> {

@sgrif sgrif Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Comment thread pgdog/src/backend/pool/shard/oids.rs
Comment thread pgdog/src/backend/schema/cache/mod.rs
@sgrif sgrif changed the title WIP: Handle OID drift between shards Handle custom types with differing OIDs across shards Jul 30, 2026
@sgrif
sgrif marked this pull request as ready for review July 30, 2026 20:17
Comment thread pgdog/src/net/messages/parse.rs
Comment thread pgdog/src/net/messages/parse.rs
@sgrif
sgrif requested a review from meskill August 3, 2026 16:02
Comment thread pgdog/src/backend/prepared_statements.rs Outdated
Comment thread pgdog/src/backend/pool/shard/oids.rs Outdated
Comment thread pgdog/src/backend/replication/logical/subscriber/tests.rs Outdated
Comment thread pgdog/src/backend/pool/cluster/schema_loader.rs Outdated
Comment thread pgdog/src/backend/prepared_statements.rs Outdated
Comment thread pgdog/src/backend/pool/shard/oids.rs
@sgrif

sgrif commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Between making sure we have adequate test coverage for RowDescription on the simple protocol, and that python test failure, this needs a bit more work.

@sgrif
sgrif marked this pull request as draft August 3, 2026 19:48
sgrif added 13 commits August 3, 2026 14:01
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
sgrif added 3 commits August 3, 2026 14:40
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.
@sgrif
sgrif marked this pull request as ready for review August 3, 2026 21:47
@sgrif
sgrif requested a review from meskill August 3, 2026 21:48
Comment thread integration/rust/Cargo.toml

@levkk levkk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread pgdog/src/backend/pool/cluster/schema_loader.rs Outdated
Comment thread pgdog/src/backend/pool/shard/oids.rs
sgrif added 2 commits August 4, 2026 12:54
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants