Support multiple pins and pin+data frame tables via a shared DuckDB executor - #313
Merged
cpsievert merged 9 commits intoSep 13, 2026
Merged
Conversation
cpsievert
marked this pull request as ready for review
September 13, 2026 17:56
cpsievert
added this pull request to stack #314
September 13, 2026 17:56
Contributor
There was a problem hiding this comment.
🔵 Needs a closer look
Fix staging-name collisions and unregister failed staging registrations before fallback in _pin_source.py.
Review details
Files not reviewed (2)
- pkg-r/man/DataFrameSource.Rd: Generated file
- pkg-r/man/PinSource.Rd: Generated file
Suppressed comments (1)
pkg-py/src/querychat/_pin_source.py:244
vnameis deterministic fromtarget, so staging relations can collide with real tables from another pin (for example, a pin named__pin_staging_pin_aregistered before an arrow/RDS pin namedpin_a). In addition, if the firstCREATE TABLEfails, its staging registration is not unregistered before this fallback reuses the same name, so the fallback can fail with an "already exists" error and leave the executor partially registered. Use a unique per-call staging name and unregister it in afinallyblock before retrying from the private copy.
vname = f"__pin_staging_{target}"
conn.register(vname, self.get_data().to_native())
conn.execute(
f"CREATE TABLE {quote_identifier(target)} AS "
f"SELECT * FROM {quote_identifier(vname)}"
- Files reviewed: 12/14 changed files
- Comments generated: 0 new
- Review effort level: Lite
Contributor
There was a problem hiding this comment.
🔵 Needs a closer look
Unresolved issues remain around pin-version snapshots and broad exception fallbacks during materialization.
Review details
Files not reviewed (2)
- pkg-r/man/DataFrameSource.Rd: Generated file
- pkg-r/man/PinSource.Rd: Generated file
Suppressed comments (5)
pkg-py/src/querychat/_pin_source.py:73
- This new module-level helper uses a leading underscore even though
_pin_source.pyis already a private module. Per the package's Python naming convention, use a regular module-level name (and update both call sites) rather than adding another_-prefixed symbol.
def _stage_frame_as_table(
conn: duckdb.DuckDBPyConnection, frame: Any, table_name: str
) -> None:
"""Materialize a DataFrame as a real table via a unique staging view."""
pkg-py/src/querychat/_pin_source.py:244
- Catching every exception here treats unrelated failures—such as a corrupt pin file, a DuckDB conversion error, or a connection failure—as a pruned snapshot and silently serves the stale private copy. Narrow this fallback to the pins error that indicates the snapshotted version is unavailable (or re-raise after verifying that condition), so real materialization errors still fail the shared executor.
# The snapshotted pin version may no longer exist on the board
# (e.g. a non-versioned board rewritten after construction); fall
# back to this source's own copy so the shared table matches the
# private connection.
_stage_frame_as_table(conn, self.get_data().to_native(), target)
pkg-r/R/PinSource.R:110
- This only snapshots
pin_meta$local$version, butPinSourcealso supports remote boards such asboard_connect()(as documented above). For those boards the resolved version may live in remote metadata; whenversionisNULL, this leavesprivate$.versionasNULL, soregister_into()can materialize the board's newer latest version while the private connection still contains the version read at construction. Capture the board-resolved version for both local and remote boards so the shared executor cannot diverge from the source snapshot.
private$.version <- private$.pin_meta$local$version %||% version
pkg-r/R/PinSource.R:110
- The resolved version is snapshotted here, but the eager non-file path still reads with the original
versionargument instead of this snapshot. If a default/latest pin is updated betweenpin_meta()andpin_read(), the private source and the shared executor can contain different versions, defeating the stated snapshot guarantee; use the stored version for the initial materialization too.
# Snapshot the resolved version so register_into() reads the same pin
# content even if the pin is updated after construction.
private$.version <- private$.pin_meta$local$version %||% version
pkg-r/R/PinSource.R:198
- This handler catches every error and then registers the private copy, so corrupt files, DuckDB conversion failures, or connection errors can be misclassified as a pruned version and silently return stale data. Restrict the fallback to the pins error that means the snapshotted version is unavailable, and propagate other materialization failures.
error = function(e) {
duckdb::duckdb_register(
con,
table_name,
self$get_data(),
- Files reviewed: 12/14 changed files
- Comments generated: 0 new
- Review effort level: Lite
Contributor
There was a problem hiding this comment.
🔵 Needs a closer look
Two moderate review findings remain unresolved.
Review details
Files not reviewed (2)
- pkg-r/man/DataFrameSource.Rd: Generated file
- pkg-r/man/PinSource.Rd: Generated file
Suppressed comments (2)
pkg-py/src/querychat/_query_executor.py:95
- This shared path registers a pin under the registry key
name, butPinSourcesanitizes non-SQL-safe names into its privatetable_name. A single-pinQueryChat(board, "sales-2026")still usesDataSourceExecutorand delegates to that private connection, so a query against the registry name fails even though the multi-pin path (and the new non-safe-name test) works. Please make the registry and source use one canonical name, or route the single-pin case through the same alias-aware executor.
for name, source in sources.items():
source.register_into(self._conn, name)
pkg-r/R/QueryExecutor.R:262
- Allowing this combination can make the actual executor and the prompt disagree: a
DataFrameSource(engine = "sqlite")registered first with a DuckDB pin is routed toDuckDBExecutor, butQueryChatSystemPromptstill derivesdb_typeandis_duck_dbfrom that first source. The model is therefore told to write SQLite SQL and misses DuckDB-specific guidance even though DuckDB executes the query. Either reject SQLite frame members here or derive prompt metadata from the shared executor.
if (
is_duckdb_family_source(new_source) && is_duckdb_family_source(first_source)
) {
- Files reviewed: 12/14 changed files
- Comments generated: 0 new
- Review effort level: Lite
Contributor
There was a problem hiding this comment.
🟢 Approval recommended
No unresolved review issues were identified.
Review details
Files not reviewed (2)
- pkg-r/man/DataFrameSource.Rd: Generated file
- pkg-r/man/PinSource.Rd: Generated file
- Files reviewed: 13/15 changed files
- Comments generated: 0 new
- Review effort level: Lite
…DuckDB executor PinSource and DataFrameSource gain a register_into() hook for joining a shared DuckDBExecutor: data frames register as before; pins re-run their file-based materialization against the shared connection (from the local pin cache), keeping their own private connection for source-level operations. The shared connection is executor-owned and closed by cleanup_executor(); the lockdown runs once after all tables materialize. check_source_compatibility() now allows pin+pin and pin+data frame groups (replacing the registration-time rejection), and build_query_executor() routes them to DuckDBExecutor. Mixed data-frame backends are still rejected, now checked against all existing DataFrameSources rather than only the first. Refs #312
…uckDB executor Mirrors the Python change: PinSource retains board/name/version/engine and gains register_into(), which re-materializes the pin (native file read for parquet/CSV/JSON, pin_read() otherwise) into the shared DuckDBExecutor connection. DataFrameSource gains the same hook via duckdb_register(). Pins with engine = "sqlite" can't live in a DuckDB executor, so multi-table groups containing them are rejected with a targeted error. Refs #312
- Snapshot the resolved pin version at construction so the shared executor materializes the same content as the source's private connection; fall back to the source's own copy when the snapshot version no longer exists on the board (e.g. non-versioned boards rewritten after construction). - Quote SQL identifiers for registry keys (pin names need not be SQL-safe) in shared materialization and column probes. - Close the shared connection if DuckDBExecutor initialization fails partway.
meta.version is a Version/VersionRaw on local boards but a plain string on some boards (e.g. Connect GUIDs); unwrap only when needed so the resolved version is always captured.
…terialization A failed first attempt in register_into() could leak its staging registration, and deterministic staging names could collide with a real table from another pin. Stage through a uuid-suffixed view and always unregister in a finally block.
- register_into() now falls back to the source's own copy only on the pins version-missing error; other materialization failures propagate. - R's eager pin_read() path uses the snapshotted version too, closing the pin_meta()/pin_read() race. - Rename the staging helper to match package naming convention. Note: meta is populated for remote boards as well (local_meta sets it from the resolved bundle id), so the R snapshot covers board_connect().
…m prompt A sqlite-engine DataFrameSource registered first in a multi-table group is served by the shared DuckDBExecutor, but the system prompt derived db_type from that first source, telling the model to write SQLite SQL. group_db_type() centralizes the routing-aware answer.
cpsievert
force-pushed
the
feat/multi-pin-shared-executor
branch
from
September 13, 2026 19:11
9df5395 to
0c48409
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #312
Stacked on #310.
Summary
Replaces the registration-time rejection of a second pin with real support, following the design in the issue:
PinSourceandDataFrameSourcegain an internalregister_into(conn)hook — pins re-run their file-based materialization (native read for parquet/CSV/JSON, polars staging for arrow in Python,pin_read()fallback) against the shared connection; data frames register as before.build_query_executor()routes pin+pin and pin+data-frame groups to a sharedDuckDBExecutor, andcheck_source_compatibility()allows the DuckDB family to mix (mixed data-frame backends still rejected, now checked against all existing DataFrameSources). Pins keep eager private materialization at construction (fail-fast errors unchanged) and own their private connection; the shared connection is executor-owned, locked down once after all tables materialize, and closed bycleanup_executor(). A grouped pin therefore materializes twice (once private, once into the executor from the local pin cache) — chosen over lazy construction to preserve construction-time errors. In R,engine = "sqlite"pins can't join a DuckDB executor, so multi-table groups containing them are rejected with a targeted message. Python rejection tests and CHANGELOG entry replaced; R rejection tests replaced and NEWS bullet updated.Verification
Python:
QueryChat(board, "pin_a").add_table(board, "pin_b")likewise works. New tests cover cross-pin joins, pin+data-frame mixing, per-table schema/validation, sqlite-pin rejection (R), and cleanup ownership. Full pytest suite (901 passed) and affected testthat files (384 passed) pass; ruff and air are clean.