Summary:
### Background
The `pg_hint_plan` extension maintains a `hints` table that allows users to "pin" hints for specific queries.
```
Table "hint_plan.hints"
Column | Type | Collation | Nullable | Default
-------------------+---------+-----------+----------+---------------------------------------------
id | integer | | not null | nextval('hint_plan.hints_id_seq'::regclass)
norm_query_string | text | | not null |
application_name | text | | not null |
hints | text | | not null |
Indexes:
"hints_pkey" PRIMARY KEY, lsm (id HASH)
"hints_norm_and_app" UNIQUE, lsm (norm_query_string HASH, application_name ASC)
```
For example, if you had a query that joins `t1` and `t2`, and you wanted to hint the planner to always use a hash join, you could add the following hint to the hints table:
```
INSERT INTO hint_plan.hints (
norm_query_string,
application_name,
hints
) VALUES (
'1234567890',
'',
'HashJoin(t1 t2)'
);
```
Note that you need to know the query id for the query you're hinting, which in this case we're assuming is 1234567890. You can get the query id either from `pg_stat_statements` or by running the query with `EXPLAIN (ANALYZE, HINTS, VERBOSE)`.
When the hints table is in use, every time a query is executed, the planner will do a lookup (using the function `get_hints_from_table`) to see if it has any pinned hints in the hints table using the following query:
```
const char *search_query =
"SELECT hints "
" FROM hint_plan.hints "
" WHERE norm_query_string = $1 "
" AND ( application_name = $2 "
" OR application_name = '' ) "
" ORDER BY application_name DESC";
```
Note that this query returns up to two rows: one for the given query id and application name, and one for the given query id with an empty application name. If two rows are returned, `pg_hint_plan` uses the hints for the given application name and discards the hints with `application_name = ''`.
In YugabyteDB this slows down query planning significantly because the hints table might be on another node.
### The hints cache
To address this problem, this diff adds a per-backend in-memory cache for the hints table, implemented as a hash table mapping (query id, application name) to `hints`:
```
typedef struct YbHintCacheKey {
const char *query_string;
const char *application_name;
} YbHintCacheKey;
typedef struct YbHintCacheEntry {
YbHintCacheKey key;
const char *hints;
} YbHintCacheEntry;
```
Now, each invocation of `get_hints_from_table` will not query the `hint_plan.hints` table; instead, it will check the in-memory hints cache. The hints cache is authoritative; if no hints are found in the cache, we know that there are no hints in the main table either and we can safely return early.
```
if (IsYugaByteEnabled())
{
bool found;
const char *cached_hints_app = yb_get_cached_hints(client_query, client_application, &found);
if (found)
return cached_hints_app;
const char *cached_hints_empty = yb_get_cached_hints(client_query, "", &found);
if (found)
return cached_hints_empty;
return NULL;
}
```
### Cache consistency & invalidation
The hint cache is always in one of two states: valid and invalid, tracked by whether `YbHintCache` is non-null. The cache starts off in the invalid state (`NULL`). If we need to do a cache lookup and the cache is invalid, we (1) delete all of the current contents of the cache, (2) scan the entire `hints` table and insert every row into the cache, and (3) flip the state to valid by setting the `YbHintCache` to point to the hash table. Once the cache is in the valid state, it becomes a source of truth and all hint table lookups use the cache.
When the hints table is modified (a hint is inserted, updated, or deleted), we mark all caches as invalid. The details of this are in the implementation section below.
### Implementation Overview
The hints cache is modeled after the cache for `pg_cron`'s jobs table. The invalidation mechanism has two parts:
1. When the extension is loaded, we install a relcache invalidation hook for the `hints` table. This hook sets `YbHintCache = NULL` whenever the relcache entry for the `hints` table is invalidated.
2. The extension also installs a trigger on the hints table. If a query modifies the hints table, this trigger will run, marking the relcache entry for the hints table as invalid on the current backend and bumping the catalog version. This will be propagated to all other backends in the cluster via the invalidation message system.
These two pieces keep the hint cache perfectly in sync with the main `hints` table (modulo the heartbeat delay).
### Implementation Details
Currently, invalidation messages are only inserted into the `pg_yb_invalidation_messages` table and broadcast to the other backends if they were generated during a DDL. Since an INSERT/UPDATE on the hint table is a regular DML, the invalidation messages we generate here would normally get thrown away when the query commits.
To ensure that the invalidation messages are picked up into the `pg_yb_invalidation_messages` table, we essentially make this trigger into a DDL, calling `YBIncrementDdlNestingLevel(YB_DDL_MODE_VERSION_INCREMENT)`, invalidating the relcache entry, and then calling `YBDecrementDdlNestingLevel()`.
Another issue with this approach is, even if we call `YBIncrementDdlNestingLevel` and `YBDecrementDdlNestingLevel`, we only pick up the invalidation messages if there was a write to a catalog table during that period. To address this, we add a global variable, `yb_force_send_inval_messages`, that we set to true before calling `YBDecrementDdlNestingLevel` that forces the invalidation messages to get broadcasted even if there was no catalog table write during the DDL. Then we reset `yb_force_send_inval_messages` to false immediately after calling `YBDecrementDdlNestingLevel`.
### Using prepared statements with the hint table
Prepared statements with the hint table work as follows. The first 5 executions of a prepared statement each use a _custom plan_, i.e. they are re-planned for each execute. On the sixth execution of the prepared statement with the same parameters, a generic plan is generated, and this plan is stored in the plan cache and used for all subsequent executions.
When the hint table is modified, we may have updated the hints for a generic plan in the plan cache. To ensure this is picked up for subsequent executions of the prepared statement, this diff makes the change of invalidating the entire plan cache on every backend when the hint table is modified by calling `ResetPlanCache()`.
### Debugging/observability
This diff adds three new YSQL metrics:
- `HintCacheHits`: Number of lookups to the hint cache that successfully find a corresponding row in the hints table
- `HintCacheMisses`: Number of lookups to the hint cache that do not find a corresponding row in the hints table
- `HintCacheRefresh`: Number of times the hint cache was refreshed (i.e. # of times it went from invalid -> valid)
### Test changes
Makes the following modifications to existing test files:
- Adds support for JSON types in the C++ `libpq` wrapper.
- Fixes the `ASSERT_STR_EQ` macro to convert arguments to `std::string` before comparing.
- Created a common interface for using YSQL metrics in C++ tests
- Move the `WaitForCatalogVersionToPropagate` function to the `LibPqTestBase` class
Jira: DB-16225
Test Plan:
```
./yb_build.sh release --java-test org.yb.pgsql.TestPgRegressExtension
./yb_build.sh release --java-test org.yb.pgsql.TestPgRegressThirdPartyExtensionsPgHintPlan
./yb_build.sh release --cxx-test pg_hint_table-test
```
Reviewers: myang
Reviewed By: myang
Subscribers: sanketh, jason, yql
Differential Revision: https://phorge.dev.yugabyte.com/D43156