db-based sharding key lookup_query mapping - #1279
Conversation
|
|
||
| fn validate_sharded_tables(&self) -> Result<(), Error> { | ||
| for table in &self.config.sharded_tables { | ||
| if let Some(query) = &table.lookup_query |
There was a problem hiding this comment.
nit: we should use pg_parse_raw::parse here:
- will validate that the query is syntactically valid
- doesn't rely on string interpolation
| // so lookup queries run on the source, which has the complete | ||
| // mapping, and fill the destination's cache, which the row | ||
| // sharder reads. | ||
| let pending = self.copy.pending_lookups(); |
| /// Execute a query with text-format parameters using the extended | ||
| /// protocol and return all rows. Parameters are bound by the server, | ||
| /// so values don't need to be escaped. | ||
| pub async fn fetch_all_params<T: From<DataRow>>( |
There was a problem hiding this comment.
refactor: we should change fetch_all and consequently execute_checked and execute to accept a generic type that resolves to either a Vec<Query> or Vec<"extended protocol request">. The protocol handling below is brittle and needs to re-use existing logic.
| if !pending.is_empty() { | ||
| match lookup::resolve(cluster, pending).await { | ||
| Ok(()) => { | ||
| let router_context = RouterContext::new( |
There was a problem hiding this comment.
nit: I'd probably construct a new router here instead of relying on internals and calling take_pending_lookups() twice.
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
| system_catalogs: SystemCatalogsBehavior, | ||
| /// Cached sharding key lookup query results. Scoped here so the | ||
| /// cache is recreated, i.e. emptied, on config reload. | ||
| lookup_cache: LookupCache, |
There was a problem hiding this comment.
I lost track of this one. We want to double check that it lives on the Cluster, i.e., we get a new version of this when we call reload_from_existing or reload.
|
Looks pretty good! I need to read it some more, but architecturally it's sound. Just a few nits around code maintenability / quality, but nothing major/blocking I think. |
| // can't be resolved fails the statement: routing by the | ||
| // untranslated value could put it on the wrong shard. | ||
| if result.is_ok() { | ||
| let pending = self.router.take_pending_lookups(); |
There was a problem hiding this comment.
These state modifications are hard to track. I think we introduced this pattern and should not do it anymore. Maybe we can return these as part of the Route? That one is created for each request, so no concerns about leaking state between calls.
| pub fn parse(&mut self, context: RouterContext) -> Result<Command, Error> { | ||
| // Pending lookups are per-statement; don't leak them into | ||
| // the next statement if this parser is reused. | ||
| self.pending_lookups.clear(); |
There was a problem hiding this comment.
Same comment re: Route as above. We shouldn't leak this between calls like this in the first place.
| } | ||
| }; | ||
|
|
||
| self.pending_lookups.extend(pending_lookups); |
There was a problem hiding this comment.
Maybe move this to QueryParserContext? A new one is created with each request, so data between calls won't leak.
| // The terminator must ship after every data row; | ||
| // hold it back while rows are deferred. | ||
| if !self.deferred.is_empty() { | ||
| self.deferred.push(DeferredRow { |
There was a problem hiding this comment.
Maybe we make this method async instead? I get the defer pattern, but it sure makes things hard to follow. I'm pretty sure (i.e., not sure at all) that this method only gets called in async contexts anyway.
| fn translate_sharding_key(&mut self, table: &ShardedTable, value: &str) -> Option<Arc<str>> { | ||
| let query = table.lookup_query.as_deref()?; | ||
|
|
||
| match self.schema.tables.lookup_cache().get(query, value) { |
There was a problem hiding this comment.
This should really be a lookup by ShardedTable (name, schema, database is the fully-qualified lookup). It's possible (but unlikely) to have two sharded databases with the same lookup query, and different lookup tables, so this could route things incorrectly.
| /// on the source cluster, which has the complete mapping while the | ||
| /// destination is still syncing, and fills the destination's cache, | ||
| /// which the row sharder reads. | ||
| pub(crate) async fn resolve_with( |
There was a problem hiding this comment.
Need to think about this one. Nothing wrong with it, but a lot of that code is duplicated, and I'm thinking we should either move it or add a function to Cluster to execute a query against a shard using round robin.
| #[derive(Debug, Default, Clone)] | ||
| pub struct LookupCache { | ||
| // Keyed by lookup query. | ||
| cache: Arc<DashMap<String, Translations>>, |
There was a problem hiding this comment.
Another thing that came up lately is we really should have memory-bound/unit-bound caching for this kind of stuff. Maybe use https://github.com/moka-rs/moka? We otherwise have the lru crate for a simpler LRU cache, which we could also use here, albeit it won't be sharded, so maybe that's why moka would be a better choice.
|
Oh and before I forget. Metrics! We need to track lookup queries: cache hits and misses and actual cache size + evictions. That one is gonna be an important metric of performance. For cache misses, we should track the latency of lookups. For those, I would follow the cluster-global metrics pattern pgdog/pgdog/src/backend/pool/cluster.rs Line 506 in b2a61dd ... which should be renamed to |
9617b9c to
aac7637
Compare
Just added metrics and configurable lookup timeout and lookup attempts! |
| #[cfg(feature = "new_parser")] | ||
| { | ||
| let ast = pg_raw_parse::parse(query) | ||
| .map_err(|err| error(format!("\"lookup_query\" is invalid: {}", err)))?; | ||
|
|
||
| if ast.stmts().count() != 1 { | ||
| return Err(error("\"lookup_query\" must be a single statement".into())); | ||
| } | ||
|
|
||
| let mut found = HashSet::new(); | ||
| for stmt in ast.stmts() { | ||
| pg_raw_parse::walk::walk(stmt, |node| { | ||
| if let pg_raw_parse::Node::ParamRef(param) = node { | ||
| found.insert(param.number); | ||
| } | ||
| }); | ||
| } | ||
| params = found; | ||
| } |
There was a problem hiding this comment.
The new parser version of this can be quite a bit simpler by breaking early if we ever see a parameter with a number > 1, and seeing if we got a value.
| #[cfg(feature = "new_parser")] | |
| { | |
| let ast = pg_raw_parse::parse(query) | |
| .map_err(|err| error(format!("\"lookup_query\" is invalid: {}", err)))?; | |
| if ast.stmts().count() != 1 { | |
| return Err(error("\"lookup_query\" must be a single statement".into())); | |
| } | |
| let mut found = HashSet::new(); | |
| for stmt in ast.stmts() { | |
| pg_raw_parse::walk::walk(stmt, |node| { | |
| if let pg_raw_parse::Node::ParamRef(param) = node { | |
| found.insert(param.number); | |
| } | |
| }); | |
| } | |
| params = found; | |
| } | |
| { | |
| let ast = pg_raw_parse::parse(query) | |
| .map_err(|err| error(format!("\"lookup_query\" is invalid: {}", err)))?; | |
| let stmt = ast.stmts().exactly_one().map_err(|_| error("\"lookup_query\" must be a single statement".into()))?; | |
| if pg_raw_parse::walk::walk_manual(stmt, |node| match node { | |
| Node::ParamRef(param) if param.number != 1 => ControlFlow::Break(()), | |
| _ => Recurse::yes(), | |
| }).is_some() { | |
| return Err(error( | |
| "\"lookup_query\" must reference exactly one parameter, \"$1\"".into(), | |
| )); | |
| } | |
| } |
There was a problem hiding this comment.
(That's going to need some extra imports and formatting)
There was a problem hiding this comment.
I would also split the whole function in two rather than a single statement, but we're also considering the old parser to be legacy at this point so it's completely fine to just not support this feature without using the new parser
| Lazy::new(|| Regex::new(r#"pgdog_shard: *([0-9]+)"#).unwrap()); | ||
| pub(super) static SHARDING_KEY: Lazy<Regex> = Lazy::new(|| { | ||
| Regex::new(r#"pgdog_sharding_key: *(?:"([^"]*)"|'([^']*)'|([0-9a-zA-Z_-]+))"#).unwrap() | ||
| Regex::new(r#"pgdog_sharding_key: *(?:"([^"]*)"|'([^']*)'|([0-9a-zA-Z-]+))"#).unwrap() |
There was a problem hiding this comment.
Just want to double check this isn't a breaking change (since I see tests were changed too).
There was a problem hiding this comment.
We use uxid style ids, so we had org_1234 as the sharding key, but I didn't realize that there was a quoting option. So in an earlier commit I added the _ to the list, but I think that rather than add support for _, I'm just going to put the quotes around it on my app side. I think the net difference in this PR should be no actual changes to this Regex::new call.
| /// How long a sharding key lookup query can run before the | ||
| /// statement waiting on it fails. Zero (a bare test cluster | ||
| /// built without config) falls back to the config default. | ||
| pub fn sharding_lookup_timeout(&self) -> Duration { |
There was a problem hiding this comment.
Defaults should propagate from pgdog-config/general.rs.
|
|
||
| attempts += 1; | ||
| if attempts > 3 { | ||
| if attempts > cluster.sharding_lookup_routing_attempts() { |
There was a problem hiding this comment.
I'm a bit hazy on this one: why do we have attempts at all? A lookup failure indicates that 1) sharding map is out of date or 2) the database isn't reachable.
I guess it could reduce the failure rate in case of 2). Just thinking out loud, this piece kinda stood out to me since we don't do retries anywhere else in the query engine, so why is this place special?
| "counter", | ||
| ); | ||
| let mut query_time = Series::new( | ||
| "sharding_lookup_query_time_microseconds", |
There was a problem hiding this comment.
We use ms with floating point 3 point precision everywhere else.
|
|
||
| params = ast | ||
| .protobuf | ||
| .nodes() |
There was a problem hiding this comment.
I'm not sure this will actually work, pg_query::protobuf::ParseResult::nodes does not actually iterate over all nodes as you might expect from the name.

Query-based sharding key lookup for
[[sharded_tables]]Implements the lazy per-key lookup design: a sharded table config can declare a
lookup query that translates an extracted sharding key value into the value
that actually gets hashed. Query returns a row: the result is hashed, and the
translation is cached, keyed by the value. No row: the statement fails with a
retryable error, and nothing is cached.
Semantics
value is translated through the cache first; the result is hashed with the
table's configured hasher and data_type to pick the shard.
one shard, round-robin), then routes with the translated value. The value is
bound with the extended protocol, so it's never spliced into SQL text.
the untranslated value, which could place data on the wrong shard. The main
way this happens in practice is traffic for a key whose row hasn't been
inserted yet; because absence isn't cached, the first statement after the
row appears translates correctly. The query should therefore return a row
for every valid value, including values that translate to themselves
(e.g.
COALESCE(parent_tenant_id, id)gives root tenants a self-row).SchemaCachepattern:it's recreated, and therefore emptied, on config reload. Since only
translations are cached and rows are looked up on first use, a reload is
only needed to pick up changes to existing rows.
mokaand sized by a newsharding_lookup_cache_sizesetting in[general](bytes, default 64MB,per cluster). The weigher counts key and value bytes plus an approximation
of per-entry overhead. Entries are keyed by the sharded table (schema,
name, column) and the value, so identical queries on different tables
can't collide. An evicted translation is simply looked up again on its
next use; the resolve loops retry bounded in case an entry is evicted
between routing attempts.
the statement with a retryable error. The timeout and the routing retry
bound are configurable:
sharding_lookup_timeout(milliseconds, default5000) and
sharding_lookup_routing_attempts(default 3) in[general].Metrics
Lookups are tracked per cluster through
Cluster::stats(), whoseMirrorStatscontainer is renamed toClusterMetrics(it was only holdingmirror counts; those now live under a
mirrorfield) and now also carriesthe lookup counters, shared with the cluster's lookup cache, which records
them lock-free. Exposed on the OpenMetrics
endpoint, per cluster (user/database labels) plus a global rollup, same as
mirror stats:
sharding_lookup_cache_hits/sharding_lookup_cache_misses(counters):cache effectiveness. A cold statement counts a miss at routing and a hit
when it routes again after the lookup resolves.
sharding_lookup_queries(counter) andsharding_lookup_query_time_microseconds(counter): lookup queries runfor cache misses and the total time they took, including failed and
timed-out ones; the ratio is the average lookup latency.
sharding_lookup_cache_bytes/sharding_lookup_cache_entries(gauges):cache size as computed by the weigher, and entry count.
sharding_lookup_cache_evictions(counter): entries evicted to staywithin the memory bound; invalidations and replacements don't count.
Validation
lookup_queryis validated with the SQL parser at config load: it must besyntactically valid, a single statement, and reference exactly one parameter,
$1. The validation runs in thepgdogcrate, matched to whichever parserthe build uses; linking
pg_raw_parseintopgdog-configunconditionallyturned out to corrupt
pg_querybuilds through duplicate libpg_query Csymbols. The table the query reads must be omnisharded, i.e. hold the same
data on all shards, since the query runs on one shard picked round-robin;
this part is documented rather than validated, because the config doesn't
name the table.
Notes
untranslated value silently splits a tenant family across shards the moment
a lookup races a row insert, and a loud, retryable error seemed strictly
better; happy to flip it (or make it a per-table option) if you prefer the
identity behavior. In my use-case we would have the ability to ensure we
can insert the row before we start using the new sharding key id.
on their own:
ServerRequest, which letsexecute,execute_checkedandfetch_alltake either simple-protocol queries or an extended-protocolbatch (anonymous Parse + Bind + Execute + Sync, parameters bound by the
server), and
Cluster::fetch_all_round_robin, which runs a parameterizedquery on one shard picked round-robin.
are decoded to their text form and translated like text values, reusing the
existing binary decoding in
sharding::Value.resharding a lookup-translated table places rows correctly.
CopyParser::shardis async and resolves missing keys inline, in row order, through the same
resolver routed queries use. Replicated statements resolve their lookups in
StreamContextthe same way. Both text and binary COPY formats are supported, sothe default
resharding_copy_formatworks unchanged. During data sync,lookup queries run on the source cluster, which has the complete mapping
while the destination is still syncing, and fill the destination's cache;
table sync order therefore doesn't matter. During the streaming phase the
destination answers lookups, which works because data sync completed and
the stream itself keeps the mapping current; a mapping row and rows keyed
by it written in the same source transaction would fail the stream, which
the insert-the-mapping-first application pattern avoids.
statement before erroring, since absence isn't cached. Complete mappings
(a row per valid value) avoid this entirely.
pgdog_sharding_keycomments andSET pgdog.sharding_key, translate through the lookup too, so there's nopartial state where a statement-extracted value translates but a hinted one
doesn't. The comment parser returns either a shard or a pending lookup; the
pending case flows into the same resolve-and-route-again path statements
use. Bare keys route through the common mapping, which now also requires
every table to agree on
lookup_query; the cache key comes from the firsttable, so hinted and extracted values share cached translations.
error (SQLSTATE 58000), matching the existing direct-to-shard transaction
check: the write must reach every shard, and honoring the directive lands
it on one, silently diverging the table. This covers
pgdog_shardandpgdog_sharding_keycomments and their SET equivalents, with a warm orcold cache; the cold case errors without running the lookup, since the
key may not have a mapping row yet when the INSERT is the statement
creating it. Reads keep the directive, since any shard holds the data.