Wrap Readers with an in-memory caching layer - #496
Conversation
thomasp85
left a comment
There was a problem hiding this comment.
This is a very preliminary review based on a first read through. Some comments in the code. Further, a more general question: How does the caching setup works in terms of e.g. a user loading a spatial extension. Is this somehow mirrored in the cache (I think not but asking because I can see issues down the line here)
| // Build the reader. A composite `<primary>+<cache>://` URI is handled by | ||
| // `reader_from_uri`; the `--cache` flag is an explicit alternative and may | ||
| // not be combined with a composite URI. |
There was a problem hiding this comment.
Part of me want the cache to come before the primary, so that the primary sits next to the arguments that define it
There was a problem hiding this comment.
Good idea, I've flipped them in 049e38a.
| repeat(choice( | ||
| seq(',', $.table_ref), | ||
| $.join_clause | ||
| )) | ||
| )), | ||
|
|
||
| // ANSI join: an operator, the target table, and an ON or USING condition. | ||
| join_clause: $ => prec.right(seq( | ||
| $.join_operator, | ||
| $.table_ref, | ||
| optional($.join_condition) |
There was a problem hiding this comment.
What is the rationale for including this grammar change in the PR? I know some of the changes addresses the need to join across primary and cache but I'm not sure why we would suddenly allow this in FROM? I can fear we open the door for extremely complex FROM queries
There was a problem hiding this comment.
As discussed, consider the following statement,
WITH t AS (SELECT 1 AS k, 100 AS v)
SELECT t.v, base.w FROM t JOIN base ON t.k = base.k
VISUALISE v AS x, w AS y DRAW point
Once the CTE is materialised, we have a situation where t.v is rewritten as __ggsql_cte_[...] and materialised in the cache, but base.w remains in the primary. So, we need a way to capture such tables JOINed in this way and materialise them in the cache also.
The current machinery for all this is based on string matching a bag of tokens, which works OK when we know we're looking for tables with names like __ggsql_cte_*. But, when we have arbitrary tables, potentially living in subqueries, we need to do proper parsing.
So, we switch to read parsing and as part of that work we extend the grammar to properly recognise ANSI SQL JOINs and windowing here.
There was a problem hiding this comment.
I have pushed a further commit to separate out VISUALISE's FROM rule from SELECTs FROM. Now, VISUALISE [...] FROM [...] creates a different node when parsing that no longer accepts the additional JOIN and windowing children.
With this additional change, VISUALISE [...] FROM [...] with anything other than a table/CTE is a parse error.
# Conflicts: # CHANGELOG.md # ggsql-cli/CLAUDE.md # ggsql-cli/src/main.rs
`__ggsql_cache_meta__` is documented as queryable for introspection, but `ensure_meta_table` sat below the cache-resident early return in `execute_sql`. A reference to the metadata table is itself cache-resident, so a session whose every read routed to the cache never created it and the introspection query failed with a catalog error.
The composite form is now `<cache>+<primary>://<rest>`, so the primary connection URI and any query parameters it carries stay contiguous: `duckdb+odbc://DSN=foo?warehouse=PROD` wraps `odbc://DSN=foo?warehouse=PROD`. Since `split_once` leaves any further `+` in the second half, the multiple-`+` rejection moves with it onto the primary scheme.
Moving the SQL helpers out of data.rs left `load_builtin_dataframe` as the only user of `GgsqlError`, and that is gated on the parquet feature. Builds without it — ggsql-wasm among them — warned on the unused import.
Derived SQL runs on the cache, whose dialect may be nothing like the
primary's, so its driver errors read as though they came from the user's own
connection: a `sqlite://` query could fail with a DuckDB extension-path
error and nothing pointed at the cache.
`CachingReader` now records the cache backend's scheme and prefixes every
compute-surface failure with it, dropping the generic "Failed to
{execute,prepare} SQL" preamble. Stage-specific preambles such as "Failed to
fetch row" are kept, since those locate the failure.
VISUALISE reused the SQL from_clause, which accepts a comma list and — since JOIN support landed — full ANSI joins, while only the first table_ref was ever read. `VISUALISE FROM a, b` silently plotted `a` alone, and `VISUALISE FROM a JOIN b ON …` silently dropped the join, so a query whose join should have filtered rows returned all of them. A dedicated `visualise_from` rule takes exactly one `source_ref`, matching the layer-level FROM. The alias slot that came with table_ref goes too: it never reached the AST and could not be referenced, since mappings take a single column name rather than a dotted one.
The message asserted that a mapping held a SQL expression, which is only the most common reason the VISUALISE side fails to parse. It now states what the clause accepts rather than diagnosing which rule was broken, so it reads correctly for a rejected FROM as well.
As discussed, non-data returning statements like For the caching reader, during computation ggsql automatically enables the relevant spatial extensions when needed. The above magic can lead to confusing errors where a |
Add an in-memory caching layer for readers
This PR adds a caching layer that wraps any reader with an in-memory caching reader, for now supporting duckdb or sqlite.
This allows us to visualise with read-only databases, and caching improves the experience for very slow remote databases.
A
CachingReaderwraps a primary reader, splitting the API into two surfaces:execute_sql) — base reads of the user's data, hitting the primary.execute_sql_cached) — all dialect-generated / derived SQL over__ggsql_*tables, hitting the cache.When caching is off, the default
execute_sql_cachedjust callsexecute_sql, so the single execution path works unchanged.Cached reads are tracked in a
__ggsql_cache_meta__table inside the cache reader, itself queryable for introspection.The cache is bounded by a TTL and an LRU budget. Entries older than the TTL are re-fetched, and once the total size exceeds the budget the least-recently-used entries are evicted until it fits.
Usage
Opt in with the
--cacheflag when using the ggsql CLI, or otherwise set a composite<primary>+<cache>://connection string:In Jupyter et al., use a composite connection string:
Force clear the cache mid-session with a meta-command:
-- @uncacheConfiguration comes from env vars (
GGSQL_CACHE_DISABLED,GGSQL_CACHE_TTL,GGSQL_CACHE_MAX_BYTES)or connection URI query params (
?cache_ttl=300&cache_max_bytes=32mb&cache_disabled=0).Defaults: enabled, 300s TTL, 512 MB.
Reader trait changes
There are four new Reader methods, all with defaults so existing drivers are unaffected:
execute_sql_cached-- the compute surface. Defaults to justexecute_sql.materialize_table-- materialize a query body. Default isCREATE … TEMP TABLEon the reader.caches_sources-- defaults tofalse,trueforCachingReader.clear_cache-- no-op by default, backs the-- @uncachemeta-command.Because SQL runs on cache-resident _ggsql* tables via the compute surface, it must be emitted by ggsql in the cache backend's dialect, not the primary's, so
CachingReader::dialect()returns the cache dialect.Read-only guarantee
When the cache is active, the primary connection is never written to:
materialize_tableis overridden byCachingReadertoregister()the resulting data frame into the cache, rather than using temp tables.execute_sql_cached, i.e. the cache backend.Grammar
The grammar has been changed to parse joins structured nodes, so every joined table is discoverable. This allows for parser-based SQL rewriting
CTE- and source-reference rewriting now runs off the tree-sitter parse tree instead of ad-hoc regex. This correctly handles comma joins, quoted / schema-qualified / whitespace- and case-variant names.
Moved the SQL-structural helpers out of
reader/data.rsinto a new fileparser/sql.rs.Mixed-residency staging
When a query body mixes cache-resident tables (CTEs, ggsql: builtins) with primary base tables, the primary tables are
staged into the cache so the whole body can run on the compute surface. This allows one to run queries like,
saleslives on the primary (via the DSN).targetsis a CTE, materialised in the local cache. The join mixes the two, sosalesis transparently staged into the cache and the join runs locally.Similarly, setup (CREATE/INSERT/UPDATE/DELETE) queries runs before CTE materialisation and staging, so a query that creates a table and then uses it works OK with caching enabled.
The same routing applies to per-layer sources. Each layer can bring its own
FROM, and with caching enabled those sources are resolved against the cache rather than the primary,so you can point one layer at a 'local_file.csv' even when the primary is a remote database:
salesis read from the remote primary,targets.csvis read locally by the cache backend. Both layers render together; nothing is written back to the primary. Without caching,FROM 'targets.csv'would only work if the primary reader itself could read that path.Bookeeping
Initially based on #423 and jimhester#2. Jim should be added as a co-author on the final merge commit.