Replies: 2 comments 15 replies
-
Some thoughts:
|
Beta Was this translation helpful? Give feedback.
-
Can you say anything about what cases those are? Are we able to detect them and not cache them, at least?
I think we want to do the recompilation before starting the postgres transaction, in order to avoid keeping the postgres transaction open for a long time. (This means that we might miss recompiling a query that came in in between, but that's probably fine). I'm also a little nervous about potential transaction serialization errors resulting from the cache, so we'll need to be potentially careful about that. |
Beta Was this translation helpful? Give feedback.
-
Motivation
The idea is to move the EdgeQL query cache from the EdgeDB server memory to the backend database.
Query cache is mostly compiled SQL to be executed. They don't change unless the database schema is updated, so they should survive a server restart to save re-compilation time (a.k.a. cache hydration). Storing the query cache in the database as a stored function also saves the network traffic between the server and the database, e.g., the server can send only the function name with arguments to the database, when an EdgeQL query is repeatedly executed. Also, the server memory footprint is reduced without the query cache (up to 1000+1000 cache entries).
Design
Headlines:
CREATE FUNCTION
NOTIFY
to sync between tenantsCompile Request Serialization
The compile request is a combination of the query itself, and all types of parameters that affects compilation, including:
It needs to be serialized for 3 reasons:
The original query string is included in the serialized data, but only the normalized query hash is considered in the cache key calculation, because:
select 42
andselect 66
At last, a numerical serialization version is also added to the binary, so that we can keep the backward compatibility while making changes to the data format in the future.
Schema version is taken care of separately; neither exists in the serialized request nor affects the cache key.
We reviewed several serialization frameworks (see also the rejected ideas) and decided to use a hand-written custom Cython serialization because:
sertypes.py
)Stored Functions
In the "high-level compiler" after a successful compilation of a cache-able query, the result SQL is wrapped in a
CREATE FUNCTION
, where:__qh_{cache_key_hex}
record
for object types, corresponding PG types for scalars,json
if the output format is JSONsetof ...
if the result cardinality is multi, or the output format isJSON_ELEMENTS
At the same time, a
DROP FUNCTION
SQL is also created (and stored in the registration table in the next step), because dropping a PG function needs not only the name but also the arguments signature. When evicting a cache entry, we can directly use this drop SQL.Pending issue: In certain cases, Postgres is not satisfied with justrecord
without a specific shape.The
CREATE FUNCTION
andDROP FUNCTION
SQL texts are added to eachQueryUnit
as a new fieldcache_sql
, while the originalsql
field stores the function call SQL, so that the majority of execution code remains the same.Performance-wise, PostgreSQL stores AST of function implementation, which saves some (if not little) parsing time on execution. EdgeDB server uses the same logic of handling prepared statements for calling (cached) functions, so this part has no change. See also the benchmarks section of a large number of functions.
Cache Management
This design proposes a new internal table
edgedb._query_cache
:QueryUnitGroup
DROP FUNCTION
All rows are loaded at server start into the in-memory LRU cache. EdgeDB queries will only look at the LRU cache for execution.
Compiled result is persisted (inserted into this table, and create the actual function) before execute in the same transaction, if it was a fresh compile. Only after this transaction is committed successfully, can we update the EdgeDB server in-memory LRU cache, so that concurrent queries won't use the cache before it's actually ready in the database.
There's a new function
edgedb._evict_query_cache()
that deletes one row and drop its registered function, used by the server asynchronously after the insertion of the in-memory LRU cache if it's full.key
andschema_version
is NOT a composite primary key, meaning we only keep cache for one version of the user schema. Before executing DDLs, we call a new functionedgedb._clear_query_cache()
to clear this table, drop all registered functions, and returninput
of all rows in the same transaction. After DDL applies successfully, we send those already-serialized compile requests to the compiler in parallel with the new schema for recompilation. We will re-create the cache before the DDL transaction ends with only successful compilation, discarding cache entries that can no longer be compiled after DDL.After any changes to the database query cache, the EdgeDB server will issue a PG notification
query-cache-changes
to other tenants on the same backend, so that they got a chance to load the changes into their in-memory LRU cache. We don't have a centralized LRU statistics across tenants to avoid the complication, trusting each tenant to have roughly the same load-balanced cache LRU statistics.Compile Result
The result of compilation is a
QueryUnitGroup
object. Except for SQL, it also has a lot of information used by the I/O server to complete query execution, like:Currently,
QueryUnitGroup
is simply pickled, but ideally, this should be replaced with a custom serialization like the request. However, the only reason for that is compatibility, so that we don't have to drop old cache after server updates.Compilation and transactions (TBD)
In general, compilation results in transactions should be temporarily stored in memory, reused within the transaction if the same command is issued multiple times. And those after DDL could be persisted when the transaction is committed successfully. This optimization was broken before this proposal, and I'm planning to add it back properly.
Scripts (TBD)
Scripts can be cached per statements under certain circumstances for better cache hit rate (hence faster compilation) and less cache storage. This requires us to handle DDLs carefully, but it's overall doable and similar to compilation in transactions.
Benchmarks (TBD)
Implementation
Rejected Ideas
Centralized concurrency control
Assume the function exists and try to execute optimistically, and only compile with cache table row locks. This introduces too much complexity and performance impacts but only for a not-so-important consistency across tenants.
Serialization Libraries
required
not working, wrong imports with--gen-onefile
).Beta Was this translation helpful? Give feedback.
All reactions