Status: proposed implementation design, not an implemented or normative wire contract. Updated: 2026-10-06. Original proposal: 2026-09-13. Scope: optional, read-only catalog query execution; Python SDK; DuckDB 1.5 explicit-query adapter; DuckDB 2.0-development automatic-pushdown adapter.
The implementation target is an optional extension of the VGI 2 protocol family, proposed for
wire version 2.2.0 under the existing protocol name vgi.v2. The current Python SDK and
DuckDB extension already use 2.1.0; this is no longer a change to an unreleased 2.0.0 contract.
New query APIs in this design remain proposals.
| Component | Current baseline | Role in this design |
|---|---|---|
vgi-python |
2029f09, package 0.42.1, wire 2.1.0 |
Worker, protocol, client, and generators |
vgi |
de68c17, wire 2.1.0 |
Existing extension and reusable execution machinery |
vgi/duckdb submodule |
105edd31b57f, haybarn-v1.5.5-rc1 |
Explicit-query adapter's current engine dependency |
duckdb-3/duckdb |
80e17fc252, v2.0-cyanoptera, 2026-10-04 |
Automatic adapter and SQL-export baseline; duckdb-8/duckdb has the same HEAD |
Original duckdb-2/duckdb checkout |
2284ca3c90, 2026-09-14 |
Older comparison baseline, not the new API target |
“DuckDB 2.0” means the inspected development API, not a released upstream compatibility claim. Wire versions, SDK package versions, and the two engine targets are separate version axes.
Add a negotiated catalog-query preparation RPC, then execute accepted queries through VGI's
existing TABLE bind / init / Arrow-result lifecycle. Provide one SDK-owned reserved executor;
do not make each catalog author implement a second streaming protocol.
Ship two engine entry points:
- An explicit
vgi_query(catalog, sql)table function, usable with the existing DuckDB 1.5 extension. The SQL runs with the remote provider's declared dialect and read-only semantics. - Automatic replacement of eligible single-catalog read queries in the DuckDB 2.0 adapter. This requires a substantially stronger promise: the remote result must satisfy the semantics and output contract of the original locally bound DuckDB query.
Keep query pushdown optional and independent of filter-v2 support. A worker that can scan tables, but cannot execute queries over its logical catalog, continues to work unchanged after upgrading to the agreed wire version. A remote backend need not be DuckDB, but the first automatic provider should be DuckDB-backed and pass an explicit compatibility suite. SQL transpilation alone is not evidence of semantic compatibility.
For automatic mode, first prototype an early bound-plan optimizer extension using DuckDB's
new LogicalPlanSQLExporter. It can keep the original logical plan while asking the worker to
prepare exported SQL, then replace the plan only on acceptance. This makes a new DuckDB catalog
hook a conditional alternative, not a mandatory first dependency. The existing consuming
RemoteExecute(QueryNode) hook still has no safe decline contract; do not route VGI through it
while using the bound-plan path.
| Updated capability | Implementation decision |
|---|---|
| Logical-plan SQL exporter and structured issues | Reuse the exporter in a pre-optimizer feasibility spike; do not build a general SQL decompiler |
BoundTableFunction, explicit parser contexts, ref-counted LogicalType |
Add a dedicated 2.0 compatibility layer before enabling automatic queries |
catalog_contents, metadata etags, version adoption |
Reuse existing metadata caches; distinguish committed metadata from transaction/data snapshots |
vgi.v2 routing and wire 2.1.0 |
Route new RPCs with generated protocol identifiers; propose coordinated 2.2.0 rollout |
| Typed nested catalog records and six worker SDKs | Use typed structs for new query records and include C# schema/type generation |
| Worker-batch validation, cancellation dispatcher, scan interrupt checks | Extend existing guards and lifecycle hooks rather than recreate them |
| Credential-aware caches and HTTP grants | Preserve current identity isolation; keep query-result caching disabled |
Filter pushdown reduces rows read from one scan. Catalog query pushdown moves an entire eligible relational computation to the catalog provider: joins, filters, projections, grouping, aggregates, sorting, and limits can execute together near the data.
For example, with decimal-valued amounts:
SELECT c.region, sum(o.amount) AS revenue
FROM warehouse.sales.orders AS o
JOIN warehouse.sales.customers AS c ON c.id = o.customer_id
WHERE o.status = 'paid'
GROUP BY c.region
ORDER BY revenue DESC NULLS LAST;The worker can return one row per region instead of transferring the qualifying order and customer rows for a local join and aggregation. This is a data-movement optimization, not a promise to eliminate metadata lookups, binding, or every network round trip.
The proposed explicit entry point exposes the same execution facility on DuckDB 1.5:
SELECT *
FROM vgi_query('warehouse', '
SELECT region, count(*) AS customers
FROM sales.customers
GROUP BY region
ORDER BY customers DESC NULLS LAST
');Here the outer query runs locally and the SQL string uses the attached provider's namespace. This syntax is an implementation target, not an existing registered function.
Original SQL
-> ordinary binding -> early eligibility and SQL export
ineligible/unsupported -> original bound plan -> normal optimization and VGI scans
eligible -> catalog_query_prepare
declined -> original bound plan
accepted -> one reserved VGI query scan
-> bind -> init -> Arrow batches -> cleanup
| Layer | Available now | Required addition |
|---|---|---|
| DuckDB development API | Consuming remote-catalog hooks; bound-plan SQL exporter; pre/post optimizer extensions | Early export feasibility, VGI source reconstruction, and accepted-plan replacement |
| VGI attachment | Client capabilities, attachment/transaction tokens, bulk catalog contents and etags | Query capability negotiation and transaction-aware metadata validation |
| VGI function execution | Dynamic bind schema, TABLE streaming, batch/type validation, cancellation dispatcher | Reserved prepared-query dispatch and stricter query execution invariants |
| Python worker | Declarative catalogs, scoped functions, state codecs, storage abstractions | Query-provider interface and transport-safe execution state |
| Python transactor | Attachment-scoped databases, transaction cursors, Arrow scan producers | Reusable query-owner integration, prepare-only metadata, explicit handle lifecycle |
| DuckDB VGI extension | Catalogs, Arrow scans, transaction integration, filter pushdown, result cache | Query binder, query-specific bind data, semantic adapter, cache exclusion |
Important limits of DuckDB's existing parsed-statement remote-catalog pass (distinct from the proposed bound-plan adapter):
- The pass runs on parsed statements before normal query binding. It identifies references to one remote catalog instance, not merely one worker URL. Two attachments to the same URL can have different credentials, options, and transactions and must not be merged.
RemoteCapability::EXECUTE_QUERY_NODEis not synonymous with SELECT-only execution. Query nodes also represent writes. The adapter must explicitly reject write node kinds as well as full-statement DDL and raw-SQLCONNECTdelegation.- The base
SupportsPushdownoverloads default to permissive behavior. The VGI adapter must override all relevant overloads with explicit allowlists and reject unknown node kinds. FinishPushdownstrips catalog qualification, moves the original query intoRemoteExecute, and wraps the returned reference inSELECT *. It neither preserves an untouched fallback candidate nor checks a nullable returned reference as a decline decision.- The wrapper does not retain an outer
ORDER BY. The replacement must preserve the remote query's ordered stream; an ordinary unordered parallel table scan is insufficient. - This is not a general distributed subplan optimizer. The inspected finishing sites include the statement root, certain INSERT/CTAS sources, and certain set-operation children. An arbitrary remote subquery inside a mixed local/remote SELECT is not guaranteed to push. Parent CTE references also cause conservative rejection.
- The pass can fold eligible scalar expressions locally. Do not describe remote pushdown as executing all expressions remotely, or assume preparation is the first possible binding work.
The newer StripCatalogName preserves every component after the catalog, including schema and
struct-field suffixes; it no longer collapses qualified columns to their last two components.
That fixes a naming hazard but does not change the consuming ownership contract above.
The preferred adapter uses OptimizerExtension::pre_optimize_function, which runs after binding
and mandatory aggregate lowering but before built-in logical optimizers. The exporter verifies
the plan and returns an owned SQL AST plus output fields or structured issues. Unsupported export
is an eligibility miss; an invalid plan or malformed extension result is an error. Export success
alone proves neither remote semantic compatibility nor authorization.
The engine's disabled_optimizers='extension' disables this adapter. Also explicitly honor
disabled_optimizers='remote_pushdown' in the adapter for a consistent operator escape hatch;
that flag does not automatically gate optimizer extensions. Neither flag needs to disable an
explicit vgi_query call.
No remote DDL, DML, COPY, ATTACH, session-control statements, multi-statements, raw CONNECT
delegation, arbitrary code execution, or unbounded streaming queries. Existing VGI write APIs
remain authoritative; this capability does not replace them.
No cross-attachment joins, upload of local temporary tables, distributed shuffle, cost-based join placement, arbitrary mixed-query subtree extraction, or serialization of DuckDB internal AST/logical-plan objects. No new Substrait implementation is required.
No automatic pushdown of parameters, correlated/LATERAL queries, recursive CTEs, windows, sampling, time travel, opaque local macro/UDF dependencies, external table functions, or queries whose order depends on an unproven physical insertion order. These can be added as independently tested profile revisions. A remote provider's broader explicit-query language must not silently broaden the automatic profile.
No query-result caching, split result readers, concurrent reads of one cursor, resumable query execution after an ambiguous failure, or cross-process cursor pickling in the first release.
The wire contract needs three distinct checks:
- Encoding:
duckdb.sql.v1means UTF-8 SQL in the declared DuckDB dialect family. It does not promise that every producer/consumer DuckDB version parses the same syntax. - Semantic profile:
vgi.duckdb.relational.v1defines the tested subset eligible for automatic execution.vgi.remote.readonly.v1permits explicitly requested remote SQL with provider-defined expression semantics, while still forbidding writes and external side effects. - Authority: the actual principal, attachment, transaction, and provider policy determine whether this particular query may access these particular objects.
Negotiate encoding and profile independently of vgi.filters.v2. This is not another filter
encoding. Do not overload filter_encodings, pushdown_filters, ScanBranches, or
table_function_plan with a serialized query.
Engine version and provider build identity inform compatibility decisions but do not prove them. A provider advertises automatic support only for tested client versions, function implementations, type mappings, and supported session contexts. Unknown combinations decline. Changes to that compatibility policy produce a new capability revision.
The following records are proposed schema definitions, not currently generated dataclasses.
Use the existing Arrow single-record IPC envelope, with typed nested structs for new query
records, following CatalogContentsResponse.schemas. Do not add a separate IPC stream per object
reference or evaluation-context record. Existing attachment/transaction tokens remain binary;
output/object schemas use Arrow IPC schema serialization, not an IPC table. Null and empty are
distinct. The existing outer CatalogAttachRequest.client_capabilities IPC encoding is unchanged.
Route preparation, bind, and init through VGI_MAIN_PROTOCOL with generated name vgi.v2 and the
selected surface version. HTTP preparation uses {base}/vgi.v2/catalog_query_prepare; raw
transports carry vgi_rpc.protocol. The SDK-owned executor name is a function argument to normal
bind/init, not a server-level reserved RPC: the __transport_options__ routing exception must
not apply to it. Register the new method in request/response schema validation and tracing.
Add nullable query_pushdown: struct<ClientQueryCapabilities> to ClientCapabilities and
nullable query_pushdown: struct<CatalogQueryCapability> to CatalogAttachResult. Null means
no query capability; decoders that allow an absent optional field use the same default. This
does not override the protocol's major/minor compatibility check. The records differ by direction:
| ClientQueryCapabilities field | Arrow type | Meaning |
|---|---|---|
encodings |
list<utf8> |
Understood versioned query encodings |
semantic_profiles |
list<utf8> |
Understood versioned semantics |
engine_version |
utf8 |
Actual client engine version/build identity |
max_query_bytes |
int64 |
Maximum UTF-8 SQL payload the client permits |
| CatalogQueryCapability field | Arrow type | Meaning |
|---|---|---|
encoding |
utf8 |
One selected offered encoding |
semantic_profiles |
list<utf8> |
Nonempty intersection with client offers |
execution_modes |
list<utf8> |
Subset of explicit, automatic |
backend_engine |
utf8 |
Provider dialect engine identity |
backend_version |
utf8 |
Backend version/build identity |
capability_revision |
utf8 |
Opaque revision of provider support and semantic policy |
default_evaluation_context |
struct<QueryEvaluationContext> |
Provider defaults used by explicit mode |
max_query_bytes |
int64 |
Positive selected limit, no larger than the client offer |
Automatic mode requires vgi.duckdb.relational.v1; explicit mode requires
vgi.remote.readonly.v1. Unknown advertised profiles can be ignored before selection. Unknown
selected encodings, modes, or required fields are negotiation errors. Never infer automatic
support merely from backend_engine == 'duckdb'.
Capabilities are immutable for an attachment. A provider rolling to an incompatible revision
must reject old preparation tokens and require reattachment or normal version renegotiation;
it must not silently change semantics mid-attachment. An old revision may remain supported during
a rolling deployment. IS_REMOTE is an engine catalog property, separate from HTTP versus local
worker transport and separate from whether query execution is enabled.
The bound-plan adapter does not require advertising IS_REMOTE or EXECUTE_QUERY_NODE just to
trigger optimization. Preserve existing VGI lookup behavior unless a separate change justifies
the generic remote-catalog accommodations. If the optional catalog-hook implementation is used,
keep IS_REMOTE stable for the attachment lifetime and test its ambiguous-schema lookup effects.
This is a unary catalog RPC. It parses, authorizes, resolves, and binds the proposed query without executing it or opening a result cursor. It may perform ordinary metadata work and bounded backend preparation; it must not fetch query results to discover their schema.
| Request field | Arrow type | Contract |
|---|---|---|
attach_opaque_data |
binary |
Existing authenticated attachment token |
transaction_opaque_data |
nullable binary |
Existing transaction token; required for the initial automatic snapshot-backed profile |
encoding |
utf8 |
Selected attachment encoding |
semantic_profile |
utf8 |
One selected profile |
mode |
utf8 |
automatic or explicit |
capability_revision |
utf8 |
Must match the attachment contract |
query |
utf8 |
Exactly one read-only query; canonicalized by the adapter in automatic mode |
catalog_version |
nullable int64 |
Attachment-local metadata version; required in automatic mode, with 0 meaning unknown |
catalog_etag |
nullable utf8 |
Last committed-metadata validator, if known; never a data snapshot token |
referenced_objects |
list<struct<QueryObjectReference>> |
Canonical object manifest; required and complete in automatic mode |
expected_output_schema |
nullable binary |
Local result contract; required in automatic mode |
evaluation_context |
struct<QueryEvaluationContext> |
Profile-specific evaluation context |
required_ordering |
utf8 |
query_order or unspecified |
Each QueryObjectReference contains schema_path: list<utf8>, name: utf8, kind: utf8, and
expected_schema: binary. The initial automatic profile permits kind='table' only. It refers
to a distinct logical catalog object, not an alias or a raw storage filename. Repeated query
aliases may refer to one manifest entry. The worker validates the query against the manifest;
the manifest is not trusted proof of what the SQL actually references. Explicit requests use
an empty manifest and resolve every object under the provider's authorized attachment namespace.
QueryEvaluationContext contains timezone: utf8, collation: utf8, default_order: utf8,
default_null_order: utf8, and preserve_insertion_order: bool. Automatic mode supplies the
effective client values, even when order defaults were expanded into SQL. The initial profile
pins other semantics-affecting engine settings to its tested defaults; the adapter declines
non-default or unknown relevant settings. Future settings require a profile revision, not an
unchecked arbitrary settings map. Explicit mode supplies the provider defaults established for
the attachment in default_evaluation_context. Secrets and connection credentials are not
evaluation-context fields. Use asc/desc for direction and nulls_first, nulls_last,
nulls_first_on_asc_last_on_desc, or nulls_last_on_asc_first_on_desc for NULL-order defaults.
Use binary for uncollated strings; additional collation/timezone contexts require provider
certification. Automatic v1 starts with binary collation and UTC context, declining other contexts
until tested. Canonical SQL still spells out each ORDER BY term's effective NULL placement.
The worker also binds table identity, virtual-column behavior, and access policy to the existing catalog contract. Positive metadata versions are comparable only within the same attachment; 0 means unknown, not "unchanged". A known incompatible metadata generation is a decline before acceptance, not license to resolve a different table with the same name. With version 0, validate the referenced objects and schemas directly in the transaction at every preparation and again as necessary before execution. An etag can help detect committed metadata changes but cannot replace transaction-local object validation. Neither version nor etag is a data snapshot ID.
| Result field | Arrow type | Contract |
|---|---|---|
status |
utf8 |
Exactly accepted or declined |
reason_code |
nullable utf8 |
Required for decline; absent for acceptance |
reason_detail |
nullable utf8 |
Bounded, sanitized diagnostic; never parsed for decisions |
prepared_query_token |
nullable binary |
Required for acceptance; absent for decline |
output_schema |
nullable binary |
Required for acceptance; absent for decline |
result_ordering |
nullable utf8 |
query_order or unspecified on acceptance |
validated_catalog_version |
nullable int64 |
Required for accepted automatic requests; 0 remains unknown |
validated_catalog_etag |
nullable utf8 |
Committed-metadata validator if applicable; null when unavailable |
context_digest |
nullable binary |
Required on acceptance; exactly 32 SHA-256 bytes |
expires_at_epoch_ms |
nullable int64 |
Required on acceptance; token expiry, not snapshot expiry |
estimated_rows |
nullable int64 |
Optional nonnegative advisory estimate |
estimated_bytes |
nullable int64 |
Optional nonnegative advisory estimate |
On decline, all acceptance-only fields and estimates are null. On acceptance, reason fields are
null, the worker must meet required_ordering, and the schema must satisfy the requested output
contract. An expected type the provider cannot preserve should produce unsupported_type, not
an accepted response that relies on the engine to coerce the result.
Define digest bytes independently of incidental IPC buffer layout. Concatenate the ASCII domain
separator vgi.query.context.v1 followed by a zero byte; then encoding, semantic_profile,
timezone, collation, default_order, and default_null_order, in that order, each as its
four-byte unsigned big-endian UTF-8 byte length followed by those exact UTF-8 bytes; then one byte,
0 or 1, for preserve_insertion_order. SHA-256 that sequence. No locale conversion, case folding,
or Unicode normalization occurs during hashing. Publish cross-language golden vectors.
The digest is an equality check and diagnostic, not an authentication mechanism. Authentication remains token sealing and the established RPC security model. The accepted token binds the SQL, object manifest, output schema, capability revision, context, attachment, principal, and transaction scope, including the automatic/explicit mode. The adapter verifies the returned context digest against its request before accepting the replacement.
Initial decline codes: unsupported_query, unsupported_expression, unsupported_type,
unsupported_context, unsupported_catalog_object, ordering_not_supported,
metadata_changed, and policy_declined. These mean ordinary VGI scan execution may still be
valid. Authentication failure, forbidden object access, malformed wire data, and backend failure
are errors, not declines. The engine must not turn an access-denied error into a less restricted
local retry. policy_declined is an optimization-placement decision, such as declining an
expensive shape, never an authorization failure. Future well-formed decline codes can use the
same pre-execution fallback policy.
Initial proposed hard limits: 1 MiB UTF-8 query text, 8 MiB decoded request envelope, 1 MiB per schema, 64 KiB prepared token, 1,024 distinct object references, 32 schema-path components, and 4 KiB diagnostic detail. Capability negotiation can lower the SQL limit. Apply envelope limits before nested decoding, validate positive bounds and integer overflow, and enforce parser depth, AST size, planning-time, and concurrent-preparation quotas independently. These are contract limits, not justification to allocate all maxima simultaneously.
Reserve wire name __vgi_catalog_query in the SDK dispatcher, with no user schema path and
function_type=TABLE. Its sole argument is prepared_query_token: binary. Do not publish it in
catalog listings or accept user definitions that shadow it. A reserved name is not a security
boundary: every call must validate capability, token, principal, attachment, and transaction.
The C++ SQL-facing function is vgi_query; the automatic adapter uses an engine-private query
scan reference. Both reach this same wire executor. The client does not receive an arbitrary
FunctionInfo from the worker and trust it to define the query executor's behavior.
bind validates the receipt and returns its exact output schema plus execution recipe in
BindResponse.opaque_data. It may rehydrate provider metadata but must not start the query.
Existing attachment and transaction fields in BindRequest must match the preparation scope.
Per-function settings/secret discovery cannot silently change that scope; the query provider
uses the already resolved attachment authority and settings. Providers needing additional
per-query semantics must negotiate them before preparation.
init starts one fresh execution using that recipe and the current validated transaction.
The existing global initialization response supplies the execution ID. Each new initialization
is a new execution; a prepared token is not a result-cache key or an exactly-once execution ID.
The fixed executor contract is:
- One ordered result reader;
max_workers=1; no secondary initializations or scan splits. - No projection, filter, dynamic/runtime-filter, sampling, row-limit, partition, or order-hint pushdown into the executor. Those semantics already belong to the accepted SQL. Disable their engine flags; reject nonempty wire hints, apart from the protocol's canonical identity/no-op representation of a full output projection. The worker never silently applies them twice.
- Every batch matches the accepted output schema and preserves row multiplicity. Empty batches are not end-of-stream unless the existing stream protocol marks completion.
query_orderrequires batch concatenation to preserve the query's result ordering. Even unordered queries use one reader initially; the backend may still compute internally in parallel.- Cancellation, terminal errors, and normal completion use the existing streaming lifecycle, with provider cleanup and expiry as described below.
- All VGI query-result cache lookup, storage, conditional revalidation, and partition-cache paths are disabled. This is an explicit bind-data property, not an assumption about naming.
No new query-data RPC is needed. New records and the preparation RPC must be in the generated schema/type inventory. Include nested structs in cross-language schema parity; retain explicit inventory registration for records that are independently serialized rather than structurally nested.
Target the high-value combination: base tables from one attachment, ordinary projections and WHERE predicates, INNER/LEFT equijoins, GROUP BY/HAVING, COUNT, exact decimal SUM, MIN/MAX, and ORDER BY with constant LIMIT/OFFSET. Ordinary nonrecursive query-local CTEs and uncorrelated subqueries can be admitted only when their entire containing query is eligible and the binding and ownership tests pass. Initially reject set operations for automatic mode rather than relying on partially finished set-operation children. Expand one syntax family at a time.
The expression allowlist includes column references, typed literals, proven exact casts, comparisons, boolean/null predicates, and individually tested arithmetic/aggregate overloads. It is an overload/type/context allowlist, not merely a list of function names. Overflow, decimal scale, division-by-zero, NULL, and collation behavior must match. Backend versions with different behavior decline even if the SQL parses.
Initially transport only boolean, signed integers through 64 bits, supported fixed decimals through precision 38, binary-collated VARCHAR, DATE, and supported microsecond TIMESTAMP results. Admit a type only after the existing Arrow-to-DuckDB conversion is demonstrated to be lossless. Reject unsupported result types rather than casting them into a convenient Arrow representation. In particular, do not assume Arrow decimal128 automatically preserves DuckDB HUGEINT identity and range. Integer SUM may produce HUGEINT; decline that result or require a query that itself produces a supported output type. Floating-point aggregates, TIMESTAMPTZ/timezone-sensitive functions, unsigned/128-bit integers, UUID/ENUM/custom types, nested types, and VARIANT wait for explicit type-profile tests. Internal intermediate types also need semantic compatibility even when they are not transported.
This deliberately narrower first profile can run useful joins and reductions. It does not pretend that “read-only SQL” establishes engine equivalence.
Let ordinary binding establish types, dependencies, resolved functions, casts, and session requirements. In the early optimizer callback, first restrict eligibility to supported read-only statement roots whose data sources all belong to one VGI attachment. Do not perform a second general validation bind. Ordinary binding can already perform metadata discovery and constant evaluation; this feature must not add query execution during export or preparation.
Use LogicalPlanSQLExporter::Export(context, candidate, options) after the source/operator
allowlist checks. It returns an owned QueryNode and positional fields with source_binding,
semantic type, and optional optimizer_type. Use those fields to verify the result contract
and build the replacement's output mapping. Do not serialize native bindings on the wire or
assume they are stable across independent exports. Use an explicit projection to preserve the
original local result layout and column bindings; leave statement result labels unchanged.
Assign unique transport-column names through LogicalPlanSQLExportOptions.output_names, and
derive expected_output_schema from those names and the validated semantic types. Local output
labels and transport names are distinct: the former remain DuckDB's original bound labels, the
latter identify the remote result by ordinal. If this mapping cannot be preserved, decline.
VGI base-table scans must export the logical catalog table, not a private vgi_table_scan call
with an opaque receipt. Use the exporter's get.GetTable() reconstruction when it retains the
right identity; otherwise add a side-effect-free TableFunction::to_sql callback to the ordinary
VGI table scan. Return an owned qualified BaseTableRef or a structured unsupported reason.
The exporter applies projection and predicates centrally; the callback must not duplicate them.
The reserved query executor itself should decline export to prevent recursion or token leakage.
On the returned AST, remove only the proven target attachment's catalog component from resolved base references. Preserve schema components, generated aliases, CTE scopes, system-function qualification, and struct-field suffixes. Use the AST writer, not SQL text replacement. Validate that every remaining data reference is in the same logical-object manifest. Reject UDFs, views, companion sources, process-local inputs, and extension operators not admitted by the profile. Expanded macros are not automatically safe because their original names disappeared: their bound expressions and every source still need the same semantic/effect checks.
The exporter is deliberately not a remote-compatibility oracle. Its same-environment contract does not prove that another DuckDB version or non-DuckDB backend implements the exported query identically. Retain negotiated profiles, overload checks, worker preparation, and output checking.
Prefer the early callback, not an export of an arbitrarily late optimized plan. The current
exporter does not undo folded statistics, literal now()/settings observations, or data-derived
empty relations. It can also reject scans whose consumed projection expressions lost provenance.
Even the early callback follows binding and mandatory aggregate lowering. Therefore initial
automatic mode requires the worker to execute in the same backend transaction/snapshot assumed
by binding and any data-dependent planning. Reject incompatible context rather than switching
silently to a later export point. Nontransactional providers remain eligible for explicit mode.
The worker must implement the same logical VGI tables. A raw backing table is not equivalent if normal VGI scans rename columns, add computed columns, enforce predicates, choose scan branches, or delegate through a companion catalog. A query provider must reproduce those rules or decline the affected object. This is especially important for row-level and column-level access policy.
VgiRequiredFiltersOptimizer currently checks required filters after built-in optimization.
Removing a scan in the early callback would bypass that check. Initially decline automatic
queries touching objects with required_filters, native-delegation placeholders, or multi-branch
markers. Supporting them later requires enforcing the same rules before replacement, not just
trusting worker acceptance. Register the query callback in a tested order relative to
VgiMultiScanRewriter; do not let branch expansion hide a source's original policy or attachment.
Reuse VgiCatalog's existing catalog_contents seeds and per-kind caches during binding. Do not
fetch a whole inventory solely because a query mentions two tables. The bulk response represents
committed metadata and has no transaction token; it cannot prove the existence/schema of
transaction-local DDL. Preserve per-name transactional lookup and invalidation behavior.
Reuse version adoption, stale-snapshot retry, and conditional etag revalidation; do not introduce
a second independent query metadata cache. Version-0/no-etag catalogs retain lazy reload behavior.
catalog_contents_attach_independent permits metadata reuse only, never reuse of query receipts,
authorization decisions, result cursors, or data across principals.
Schema paths are component lists throughout resolution. A component containing a dot is not two
schemas. The current DuckDB 1.5 adapter's depth-one bridge must not flatten a deeper worker path.
Do not silently change the existing attachment default_schema: str contract in this feature;
automatic SQL uses resolved object paths and explicit mode uses the provider's declared namespace.
Compare the prepared output to the expected transport schema and local ordinal mapping before accepting a rewrite: column count, order, transport names, and logical types must match without implicit casts or dropped fields. Preserve the original local result labels separately. Define the profile's Arrow normalization explicitly; do not compare byte-for-byte IPC envelopes or disregard type-significant metadata. A batch may not violate accepted nullability. Validate subsequent batches as well as bind-time metadata.
The legacy SELECT * FROM <table function> wrapper can introduce name deduplication. The preferred
bound-plan replacement avoids that wrapper and uses unique transport names plus the local output
mapping. Duplicate local labels are supported only after tests prove they remain unchanged; until
then decline them conservatively. Explicit vgi_query follows normal table-function naming rules.
Reuse ValidateWorkerBatch and ValidateProjectedWireBatch before Arrow conversion, including
externalized/shared-memory batches. They already defend buffer contents and wire column types.
They do not establish result-name, collation, field-metadata, or nullability semantics, and the
global validation setting can disable them. The query adapter's exact schema/semantic checks
remain mandatory independently of that performance setting; do not equate structural validation
with query equivalence. Empty projection_ids means the full output schema in the current client.
Make default order direction and NULL placement explicit in canonical SQL. DuckDB exposes these
as settings, so relying on the worker's defaults can change results, especially with LIMIT.
See the official ORDER BY documentation.
An accepted ordered result must use DuckDB FIXED_ORDER and a single effective reader through
all initialization paths. Check parallelism after the worker's init response is applied, not
only in the initial function declaration.
Ordering is distinct from deterministic tie-breaking. A query with nonunique ORDER BY keys can have multiple legal tie orders; do not invent a tie-breaker. Tests compare ties accordingly. Unordered LIMIT and order-sensitive aggregates are excluded initially. A plain projection/filter can preserve insertion order in DuckDB; the provider must not replace that with an arbitrary backend order. The initial automatic policy skips these low-value plain-scan shapes. General order behavior is documented in DuckDB's order-preservation reference.
Do not transplant a whole client session into the backend. Copy only negotiated evaluation context, validate all other relevant settings against the profile, and use isolated backend connections so concurrent requests cannot race through SET statements. Queries involving clock, randomness, sequences, side-effectful functions, environment/connection identity, filesystem or network access, and remote-only functions are not automatic-profile candidates. The same side-effect restrictions also apply to explicit mode, even where expression results are otherwise provider-defined.
Register a 2.0-only OptimizerExtension::pre_optimize_function. Limit the first implementation
to complete eligible SELECT roots; explicitly recognize supported EXPLAIN/PREPARE wrappers or
decline those wrappers until their lifecycle tests pass. Do not traverse arbitrary mixed-query
subtrees, DML sources, or local joins in this milestone. The optimizer owns the original logical
plan throughout export and worker preparation.
Original bound plan remains owned by the optimizer
-> eligibility and snapshot checks
-> LogicalPlanSQLExporter::Export
unsupported -> return without substitution
invalid plan / malformed callback -> error
-> canonical AST and expected result mapping
-> catalog_query_prepare
declined -> return without substitution
accepted -> build and verify an independent replacement
-> swap only after all checks pass
Export can resolve/cache logical types during verification; do not promise byte-identical native objects after the attempt. The guarantee is no semantic or ownership-changing replacement on decline. No moved-out original plan, no second local execution, and no catch-all fallback on errors. Perform ordinary local optimization afterward whether the candidate was retained or replaced. Ensure that later passes cannot duplicate a nonrepeatable remote execution or push forbidden hints into the executor.
The first technical gate is a compiling prototype against 80e17fc252 proving export of a
VGI join+aggregate at this early stage, output/type/binding preservation, snapshot identity,
and successful normal optimization after a decline. The exporter accepts planned trees but
does not select a safe stage for its caller; its README explicitly calls distributed fragment
export experimental. If this gate fails for a shape, decline that shape. Do not claim all
exportable DuckDB queries are automatically remote-capable.
If the early bound-plan route cannot meet the required coverage, a separate engine change can
add the previously proposed TryRemoteExecute(context, const QueryNode &original) behavior:
return an accepted TableRef, no replacement on decline, or an actual error. The optimizer must
retain the qualified original and call before StripCatalogName or any ownership transfer.
Use an explicit opt-in so existing consuming RemoteExecute implementations are unchanged.
Both statement and nested-node finishing sites need tests for CTE ownership and error context.
This alternative requires its own local validation bind and output preservation checks. It is not part of the preferred initial implementation and must not run alongside the bound-plan path for the same candidate. If neither route passes the integration gate, explicit queries can ship without automatic pushdown. Reconstructing a consumed query from guessed qualification remains unacceptable.
The preferred callback identifies attached VGI scans directly and checks the current session's
pushdown setting before making preparation RPCs. It leaves VGI's parsed-statement
EXECUTE_QUERY_NODE, EXECUTE_STATEMENT, and CONNECT delegation disabled. Do not store
per-session settings in shared catalog state. Adding INSERT/CTAS source pushdown later is a
separate read-source optimization; it must not imply atomic cross-database writes.
Port the execution adapter against current 2.0 APIs, not the September headers:
TableFunctionBindInput.table_functionis aBoundTableFunction; retained call arguments andFunctionSignaturebelong to the bound/declaration split. Usenamed_argument_map_tand the new serialization callback types in the 2.0 build without changing the portable wire encoding.- Parser construction is explicit (
Parser(context), context plus identifier-case mode, or explicit options). Generated SQL must preserve resolved identifier spelling and allowed dialect semantics; do not use a nonexistent default constructor or inherit arbitrary dialect extensions into the wire profile. LogicalTypenow owns intrusive ref-counted type information. Use supported copying and type comparison APIs, including collation-sensitive checks where needed; no layout assumptions or serialized pointers to type information.- Use
SupportsNestedSchemas,ResolveEntryName, and schema-path APIs where appropriate, but never split a known component on dots. String-path lookup and already-resolved identifiers are different inputs. Keep the 1.5 depth-one compatibility rule in its adapter. - Leave the executor's new
is_repeatablecallback absent/false initially. Read-only, metadata frozen, and one prepared recipe do not prove repeated executions return a stable result. Do not attach aTableCatalogEntryto the synthetic executor that would incorrectly classify it as a repeatable ordinary table scan.
Construct a dedicated query bind-data type or an explicit discriminated query variant in the
existing bind data. It owns the attachment reference, canonical preparation request, expected
schema, preparation receipt, context/capability digests, transaction scope, and query-cache
exclusion. Retaining the request permits bounded repreparation without reconstructing SQL from
an opaque receipt. Do not hide correctness state in raw AST pointers, a global map indexed by a
user-visible integer, or a FunctionInfo blob with unconstrained feature flags.
The SQL vgi_query(catalog, sql) arguments must be bind-time constants in the first implementation.
It prepares in explicit mode and learns output schema from the worker. It does not parse remote
SQL using DuckDB 1.5 and then claim to validate the remote engine's syntax. The worker performs
the authoritative read-only parse and access check. Automatic mode, by contrast, begins with a
locally valid, locally bound DuckDB query.
Register the final scan's database read dependency through DuckDB's normal statement-properties API. Initially mark queries as always requiring rebind, including prepared SQL statements, so later execution cannot reuse a receipt from a previous transaction, attachment generation, metadata version, or session context. Preserve DuckDB's prepared-statement output compatibility rules when rebinding changes schema. Rebind is not permission to change column types silently.
Copy() must preserve owned immutable metadata and reset execution-local state. Persistent plan
serialization must never embed reusable credentials or a live transaction/cursor. Initially
reject persistence of prepared query receipts; if engine plan serialization is required, serialize
only a versioned logical recipe and resolve/authorize/reprepare it in the destination context.
Keep this separate from in-process copies of prepared statements.
| Event | Automatic mode | Explicit mode |
|---|---|---|
| Capability/profile absent | Keep existing local plan | Clear unsupported-capability error |
| Local eligibility fails | Keep existing local plan, no prepare RPC | Not applicable; provider validates remote query |
| SQL exporter reports an unsupported construct | Keep original bound plan; record bounded reason | Not applicable |
| SQL exporter reports invalid bindings/types or a malformed callback | Fail; do not hide a broken native plan | Not applicable |
| Worker returns a valid decline | Keep existing local plan | Error with sanitized decline reason |
| Metadata changed before acceptance | At most one refresh/recheck, then local plan | Refresh/reprepare or report change |
| Permission/authentication error | Fail; do not retry locally | Fail |
| Invalid schema, invalid response, preparation timeout/backend error | Fail with diagnostic | Fail with diagnostic |
| Token expires or becomes invalid after replacement | Rebind/reprepare only under the same verified contract, or fail | Same |
| Init has an ambiguous transport failure | Fail; do not start another execution automatically | Same |
| Error after init or after any output | Abort stream and clean up; never restart locally | Same |
The commit point is installation of the accepted replacement, not receipt of the first result batch. Eligibility/export misses and explicit worker declines before that point keep execution local; actual query/protocol/backend errors do not authorize fallback. Repreparing an expired receipt is bounded, must preserve the already bound output/context/transaction, and does not mean executing the original query locally after failure.
Bind/preparation are repeatable because they do not execute queries; existing reconnect-and-bind
logic can be reused only under that invariant. Starting a query is a separate operation. The
current InitRequest.execution_id distinguishes secondary initialization and must not be
repurposed as a new client idempotency key. If reliable resumable execution is added later, give
it an explicit execution-attempt and batch-sequence contract.
Add an optional query_provider to the declarative catalog, absent by default. The SDK obtains
attachment-specific state through the normal catalog context rather than storing one database
connection on a class shared by every user. A proposed author-facing interface is:
CatalogQueryProvider
capabilities(attach_context, client_offer) -> capability or None
prepare(query_context, request) -> PreparedQuery or QueryDeclined
start(execution_context, prepared_recipe) -> serializable execution handle
read(execution_context, handle, continuation) -> batch and next continuation or EOS
close(execution_context, handle, reason) -> None
These are SDK methods, not five new wire RPCs. The SDK converts preparation outcomes into the
wire response and adapts start/read/close to the reserved TABLE executor. The framework owns
validation, receipt sealing, output checks, cancellation bridging, telemetry, and limits. A
provider owns dialect validation, logical-catalog equivalence, backend transaction integration,
and actual execution resources. Capability advertisement must be cheap and not execute queries.
The provider must enforce logical-table restrictions in explicit mode too: vgi_query is not an
escape hatch around required filters, row/column policy, or unsupported scan branches. The reference
provider should decline such objects until it can reproduce their existing contract safely.
PreparedQuery contains a portable preparation recipe and output metadata, not a live result
cursor. Recipes may be self-contained sealed data or identifiers into shared, bounded TTL
storage. Large SQL recipes will need storage because token size is capped. No new release RPC
is necessary for recipes: abandoned EXPLAIN/PREPARE attempts expire without executing anything.
Receipt expiry gates starting a new execution. It does not interrupt an already accepted running execution, whose owner handle has its own deadline and idle lease. Transaction termination, attachment revocation, and authorization changes can invalidate that handle independently.
Register one SDK-internal executor and route it using the current validated attachment. Do not
dynamically generate a worker function class for each SQL string. The reserved dispatch path
must work in _resolve_function during initial bind and HTTP producer rehydration, not only
in the first Worker.bind call. It must not leak into ordinary global function registration.
The current Python TableProducerState serializes user state only when it has an appropriate
codec; otherwise HTTP rehydration falls back to initial_state(). A Python database cursor in
generator state can therefore restart the query after a worker/process change. Reusing the
existing generator machinery without fixing this ownership model is incorrect.
The query executor's serialized state should contain only a handle/owner locator, attachment and execution identifiers, continuation/batch position, and terminal state. Never pickle a connection, cursor, C pointer, or process-local registry reference as a cross-worker execution handle.
For the reference DuckDB-backed provider, use a bounded execution-owner service/actor holding the connection and cursor. HTTP turns route through the handle to that owner; a lost owner fails the stream, it does not cause another process to rerun SQL. For in-process/subprocess transport, the same abstraction can have a local owner with lifetime tied to the execution. A provider that cannot route to a cursor owner may materialize ordered Arrow fragments to shared storage and stream them by position instead; document its latency and storage tradeoff.
The existing vgi/transactor/ is the first reuse candidate, not a reason to build an unrelated
second connection manager. TransactorImpl already owns databases by attachment, cursors by
transaction, per-transaction locks, and Arrow-producing scans. Extend that ownership abstraction
behind the provider where appropriate. Its current _ScanState holds an in-process Arrow reader;
it is not itself a portable HTTP continuation, and its public scan method is not a general
read-query prepare/execute contract. Private owner RPCs may need extension even though the public
VGI data plane remains TABLE streaming.
The checked-in transactor also depends on the VGI DuckDB-Python fork's subcursor() to share a
transaction between read cursors; its compatibility shim explicitly says stock DuckDB/haybarn
builds do not yet provide that API. Gate that implementation on a tested build or a different
proven transaction-owner implementation. Do not claim that installing the ordinary optional
DuckDB package supplies this behavior. Network routing to a shared owner is also additional work,
not a property supplied by an in-process transaction dictionary.
Preparation must use genuine parse/bind/describe functionality. Do not generalize the existing
transactor's table-schema SELECT ... LIMIT 0 execution into a supposedly execution-free query
prepare. A backend's lazy relation metadata API may be usable after validation, but conversion
to an Arrow reader or result table starts execution and belongs in start, not prepare.
At most one read is in flight for an execution. Apply bounded batch sizes, backpressure, owner quotas, and idle/total lifetimes. Where the existing transport can replay a continuation, either replay the identical previously produced batch from a bounded acknowledgement buffer or reject the replay cleanly. Never advance a cursor a second time under the same continuation and call that successful replay. The first release does not promise recovery from ambiguous stream loss.
close is idempotent and runs on EOS, cancellation, error, transaction end, and expiry. Cancellation
propagates to the backend's interrupt mechanism and releases the read transaction/cursor. Existing
on_cancel is best effort, so owner leases/TTL cleanup are still required for disconnected clients.
Use cancellation-aware blocking I/O; a serial result reader must not serialize cancellation behind
an indefinitely blocked fetch. Coordinate owner lifetime with connection-pool return.
Reuse the extension's VgiCancelDispatcher and prefetch-slot ownership rather than create another
destructor-side network path. Early-exit cancellation transfers ownership off-thread; a refused
enqueue drops the connection, not returns it to the pool. Test saturation, shutdown, in-flight
prefetch, disabled cancellation, and the WASM explicit-start path. A query executor must never
pool an incomplete result stream simply because cancellation is disabled or unavailable: close
or poison it and let the owner lease expire. Reuse the scan's new interruption checks, and the
Python client's early-close cleanup, without treating either as a remote deadline guarantee.
For a transactional catalog, preparation and execution use the same authenticated VGI transaction scope; all query reads see the same backend transaction/snapshot as ordinary catalog reads in that scope. A fresh independent backend connection is not acceptable unless it joins that exact transaction/snapshot. This may require the same owner service used by the catalog's transaction implementation. Validate this integration rather than assuming that echoing an opaque token establishes a shared transaction.
Multiple query scans can exist in one local statement or transaction, including two explicit
vgi_query calls. An owner must not let the second cursor reset the first cursor's connection.
Use backend-supported independent cursors within the same transaction, serialize/materialize
under bounded policy, or reject the unsupported execution context. Do not solve this by opening
an unrelated transaction on a second connection. A provider without correct transaction support
must decline that context rather than advertise snapshot equivalence.
For a nontransactional catalog, the provider must still execute the query with the consistency its backend promises for one statement. It must not advertise multi-statement snapshot isolation. Such providers can implement explicit queries, but the initial automatic profile declines their context because it cannot prove a shared binding/planning/execution snapshot. Capability negotiation should omit automatic mode for these providers rather than issuing predictable failing preparations. Concurrent metadata changes between prepare and execution are revalidated before starting; an incompatible schema/policy change fails rather than returning differently interpreted columns.
Explicit and automatic execution do not bypass the attachment's resolved data version. Time
travel remains excluded until query-level and per-table snapshot bindings have a defined contract.
catalog_version_frozen freezes metadata, not table contents. Neither catalog metadata etags nor
versions validate a query result. The current extension also isolates caches per database and keys
secret-dependent results with credential fingerprints/HMACs; those improvements establish identity
isolation, not query-data freshness. Query scans must still bypass lookup, store, revalidation,
and partition-result caches. Reuse the per-database owner for any future query caches rather than
introduce a process-global singleton.
Parse exactly one statement using the provider's real parser or prepared-statement API. A SELECT prefix check, keyword blacklist, or regular expression is not a read-only validator. Inspect nested statements, CTE bodies, table functions, macros, and callable overloads too. Use backend read-only execution and least-privileged credentials as additional enforcement, not replacements for logical catalog authorization. Read-only database mode alone does not prevent filesystem, network, extension-loading, or side-effectful function access.
The attachment is the namespace boundary. Fully qualified references to another database, system objects exposing unauthorized metadata, external paths, and hidden backing tables remain forbidden unless intentionally exposed through the authorized logical catalog and allowed by the profile. SQL identifiers are quoted through the engine writer; SQL text is never shell-interpolated.
Receipts and execution handles are unforgeable, bounded, expiring, and bound to principal, attachment generation, policy revision, transaction, and context. Validate on prepare, bind, init, read, and cancellation as appropriate. Do not rely on handle possession alone when the authenticated caller changes. Apply existing token-sealing/storage machinery and review scope checks for the new dispatcher path explicitly.
Use the established CallContext.auth identity and attach-token binding. HTTP now accepts sealed
grants and resolve_token bearers through the existing authentication chain; do not add a second
query-specific bearer or identity protocol. A query preparation receipt is not an identity grant.
Capture and revalidate effective authority, including relevant grant restrictions/policy revision,
not just a principal string. The same principal with narrower authority must not redeem a receipt
prepared with broader access. Actual object authorization remains the provider's responsibility.
Declared secret ATTACH options remain the credential input. Preserve their redaction and existing
HMAC-based cache identities; do not add credential values to SQL, evaluation context, diagnostics,
or native serialized plans. The explicit API takes an attached catalog name, not an arbitrary
LOCATION. Reuse its already authorized connection path and existing vgi_allowed_transports
policy. Current policy deliberately allows previously attached catalogs to continue after
narrowing; do not silently change that rule or expose a new attach/transport bypass through queries.
Remote SQL can contain personal data and literals. Default EXPLAIN/logging shows a redacted shape, profile, attachment alias, query fingerprint, acceptance reason, and expected result schema, not raw SQL or token bytes. Do not use high-cardinality SQL fingerprints as metric labels. An explicit privileged diagnostic option may expose SQL locally; it must not expose credentials.
Execution deadlines use the remaining query budget, not a fresh full timeout at each stage. Define how the existing RPC cancellation/deadline mechanism reaches both preparation and the cursor owner; if it cannot carry remaining budget, add that transport field explicitly before claiming end-to-end deadlines. Cap worker planning time independently. DuckDB's local execution timeout alone cannot reliably interrupt all remote planning or a blocked network call.
Bound per-principal concurrency, preparation storage, cursor count, buffered result bytes, spill space, and backend memory. Remote execution changes where resource costs occur, even for SELECT. Attachment policy may decline expensive shapes without disguising actual backend failures as capability misses.
Propose a vgi_query_pushdown engine setting with off, auto, and require values. Initially
default to off while compatibility tests and a reference provider mature. auto tries only
the tested subset and otherwise keeps normal VGI scans. require is a development/diagnostic
mode: a detected single-VGI-catalog candidate must be accepted or produce an actionable error;
it is not an instruction to push writes or mixed-catalog queries. Queries with no such candidate
are unaffected. Explicit vgi_query is an intentional separate entry point.
Declare this session setting in vgi/src/vgi_settings.json and regenerate with
scripts/generate_settings.py, using the existing flat-settings validation and scope rules.
Do not add an unvalidated registration or a second stored setting on VgiCatalog.
The first automatic policy is capability/shape based, not cost based. Prefer eligible joins, aggregations, DISTINCT once supported, and ordered reductions; skip trivial scans. Keep estimates optional and explicitly advisory. An early bound-plan extension is not a full distributed cost model, and incomparable local/backend cost units should not decide correctness-sensitive rewrites. Benchmark before enabling automatic mode by default for a certified provider.
Local EXPLAIN may prepare but must not execute the remote SELECT. Show a VGI_REMOTE_QUERY node
with provider/profile, redacted operation summary, result columns, order guarantee, and optional
estimates. EXPLAIN ANALYZE executes normally and adds preparation time, time to first batch,
execution time, rows/bytes received, and cancellation/cleanup outcomes. Query declines appear in
debug diagnostics without flooding ordinary successful query logs.
Useful counters: candidates, local ineligibility by bounded reason, worker declines by bounded reason, accepted preparations, started/completed/cancelled/failed executions, schema violations, token expiry, active owners, and transferred rows/bytes. Compare actual traffic with pushdown disabled; do not invent a “rows avoided” metric when the baseline is unknown.
Start two tracks: prove the early bound-plan exporter against the current 2.0 headers, and define the portable protocol/execution path. The first usable release remains explicit queries on 1.5; automatic mode depends on the exporter/snapshot/policy gate, not on optimistic API assumptions. The seven implementation units below are milestones, not claims that any query API is already built.
Pin the vgi.v2 / proposed 2.2.0 contract and turn the reviewed wire portion into a normative
optional-extension specification. Specify typed nested records, metadata version-0/etag rules,
and the initial automatic snapshot requirement. Add accepted and declined fixtures, exact Arrow
schemas, malformed payloads, capability intersection cases,
and a language-neutral corpus under conformance/catalog-query-v1/. Pin semantic profile cases
and type mappings before advertising automatic support. Add a reference stub provider that can
accept, decline, and return deliberately malformed responses without executing a database.
Primary files: vgi/protocol.py, vgi/catalog/catalog_interface.py, vgi/catalog/descriptors.py,
vgi/worker.py, vgi/invocation.py where necessary, and a new vgi/catalog/query.py module.
Add capability/prepare dataclasses, RPC method, provider interface, reserved name protection,
and consistent authorization/rehydration routing. Keep general TABLE execution unchanged for
ordinary functions.
Update vgi/codegen/_common.py's inventories and generated protocol/schema artifacts for C++,
TypeScript, Rust, Go, Java, and C#. Regenerate the existing typed model/builders as well as schemas,
protocol versions, and protocol-name artifacts; preserve the routing name vgi.v2. Extend
inventory, nested-schema/type parity, protocol-name/version, catalog-auth-binding, and client-catalog
tests. Add a Python client query convenience method that uses the same preparation and TABLE path;
do not bypass the public client lifecycle with a private standalone HTTP call.
Use scripts/regen_generated.py for the coordinated generation workflow rather than redirecting
individual generators over checked-in files. Include tests/test_generated_cpp_protocol_name.py
and tests/test_generated_csharp.py alongside the existing schema/version drift tests.
Implement bounded preparation storage, serializable execution handles, owner routing, backend
interrupt/cleanup, and transaction reuse, starting from vgi/transactor/server.py and its
transaction-cursor model where it fits. Any private transactor protocol/client extensions remain
behind the provider abstraction. Provide an opt-in DuckDB-backed example using the
SDK's existing optional DuckDB-compatible engine resolver. Do not make DuckDB a mandatory
dependency for all VGI workers. Advertise explicit support first; certify automatic support only
after differential conformance against the actual client engine builds.
In the vgi repository, extend capability construction/parsing in src/vgi_rpc_types.cpp and
src/include/vgi_catalog_metadata.hpp, with attachment state in
src/include/storage/vgi_catalog.hpp / the storage implementation. Route the preparation method
through src/vgi_catalog_api.cpp, src/include/vgi_catalog_rpc.hpp, pooled unary RPCs, and
VGI_MAIN_PROTOCOL; register generated schema validation in src/vgi_schema_registry.cpp.
Register vgi_query in src/vgi_extension.cpp and implement a dedicated query binder/scan adapter.
Reuse the connection/Arrow scan lifecycle from src/vgi_function_connection.cpp and
src/vgi_table_function_impl.cpp, batch guards in src/vgi_batch_validation.cpp, and cleanup in
src/vgi_cancel_dispatcher.cpp, with explicit cache exclusion and fixed execution flags. Reuse
VgiCatalog metadata seeds/revalidation rather than introducing another inventory loader.
Audit protocol routing, reconnect behavior, Copy(), serialization, transaction dependencies,
secret options, grants, and LOCATION policy. Use the generated settings registry for new controls.
This delivers useful protocol capability independently of the DuckDB 2.0 port. Test it against the existing scan path and all supported worker transports. Do not retrofit an unrelated optimizer extension into 1.5 as part of this milestone.
Run the feasibility spike at the start, then land the proven adapter foundations here. Compile
against duckdb-3/duckdb at 80e17fc252: update the declaration/bound-function split, signature
and argument APIs, explicit parser contexts, type ownership, and serialization callbacks in an
isolated 2.0 compatibility layer. Add VGI table-source SQL reconstruction and structured export
diagnostics. Leave the reserved executor nonrepeatable and nonexportable by default.
Prove whole-root join+aggregate export before built-in optimization, unchanged local behavior on export/preparation decline, exact output remapping, and transaction snapshot reuse. Test callback order relative to multi-scan rewriting and required-filter enforcement. Porting the VGI execution path to the 2.0 API is real work even though the protocol remains portable.
Only if that gate demonstrates a specific unresolvable limitation should a separate engine PR implement the optional prepare-before-rewrite catalog hook from section 7.2. The October exporter means a core change is no longer the assumed critical path.
Add a separate version-specific adapter, for example
src/storage/vgi_remote_query.cpp plus its header. Register the early optimizer callback and
implement bound-source eligibility, SQL export, canonical name/type/context validation, prepare
RPC, exact output mapping, and verified plan replacement. Start with whole SELECT roots, no
required-filter/branch placeholders, and a shared backend transaction. Reuse execution from PR 4
and the compatibility/export layer from PR 5. Keep version-dependent DuckDB headers/hooks outside
the portable VGI wire layer and the 1.5 build. Do not enable the consuming remote-catalog pass too.
Run semantic, cross-process, transaction, security, and fault-injection suites. Document the
provider contract, opt-in catalog example, vgi_query, EXPLAIN, decline reasons, and rollout
settings. Publish compatibility claims for tested engine/provider combinations, not just a
minimum semver. Enable auto by default only for certified providers after performance evidence.
- Capability absent, empty intersections, unknown selected IDs, version mismatch, size limits, malformed nested structs/schema IPC, duplicate/invalid fields, null versus empty, and invalid status unions. Check typed models/builders as well as schemas across all six worker SDKs.
vgi.v2routing on raw transports and HTTP; new-method schema registration; protocol-name and surface-version mismatch; the reserved executor still using normal routedbind/init.- Bulk metadata enabled/disabled, version 0 with/without etag, stale snapshot retry, etag not-modified, transaction-local DDL, and two attachments with coincidentally equal versions.
- Prepared token replay across users, attachments, transactions, capability revisions, and expiry; reserved-executor calls without negotiation; user definitions attempting to shadow the name.
- Exact result schema and row multiplicity; empty/all-NULL results; zero-row grouped versus ungrouped aggregates; duplicate joins; LEFT JOIN null extension; decimal boundaries and overflow.
- ORDER BY default overrides, ascending/descending combinations, NULL placement, multiple batches, constant LIMIT/OFFSET, nonunique ties, quoted aliases, duplicate names, and non-ASCII identifiers.
- Quoted schema components containing dots, different local/remote default schemas, shadowed table names, same-URL distinct attachments, views/companions, and logical-versus-backing-table mismatch.
- Unsupported parameters, volatile/UDF/macros, nested write constructs, external table functions, unsupported types/settings, CTE scope, local correlations, and two-catalog joins decline safely.
- Remote exception versus legitimate decline: only the latter follows the local path.
- Same principal with narrowed grant authority; secret ATTACH options; token/SQL redaction; new query APIs cannot bypass LOCATION restrictions or replay authorization from metadata caches.
Run each accepted automatic query against an immutable fixture with pushdown off and auto;
compare output names/types and result bags or ordered sequences as specified by the query. For
nonunique order keys, validate the permitted tie groups rather than demanding an arbitrary
identical tie order. Add metamorphic and generated query cases to catch missing overload checks.
Use instrumentation to prove accepted queries execute exactly once and declined queries never
start remotely. On mutable data, use a shared pinned snapshot when comparing paths.
- Early bound-plan export on the actual 2.0 headers; unsupported-export decline versus invalid binding/type error; exact semantic/optimizer-type mapping; unchanged local result labels.
- Constant/settings/statistics folding and snapshot changes; reject a provider unable to share the planning transaction. Never fall back to late optimized SQL as an unvalidated workaround.
- Required-filter rejection survives the new callback; native-delegation and multi-branch markers are not hidden; scalar/aggregate and table-buffering rewrites do not acquire new bypasses.
TableFunction::to_sqldoes not execute, duplicate filters, or serialize receipts; the query executor remains nonrepeatable and cannot recursively export itself. Respect bothextensionandremote_pushdownoptimizer opt-outs.- EXPLAIN and PREPARE do not execute; EXECUTE rebinds under changed transaction/settings/metadata; persistent plans do not retain live receipts; detach/reattach invalidates old attachment handles.
- Set
threads=1and a larger value; ordered streams stay ordered and one effective VGI reader remains after init. Backend parallelism does not produce multiple client cursor consumers. - HTTP prepare on process A, bind on B, and successive reads on C/D do not restart execution. Owner loss fails safely; replayed continuation never skips or duplicates acknowledged rows.
- Cancellation during prepare, init, blocked fetch, and after partial output; idle abandonment, normal EOS, transaction commit/rollback, and double-close all release resources.
- Cancel-dispatcher refusal/shutdown, WASM explicit start, disabled cancellation, and in-flight prefetch never return an incomplete query connection to the pool. Test Python client early close.
- Malformed buffers and wrong wire types through normal/externalized/shared-memory paths; verify
mandatory query-contract checks still run when general batch validation is configured
none. - Transient bind reconnect does not execute twice; ambiguous init and post-output failures never trigger local replay or a second remote query. Verify the actual RPC client's retry settings.
- Same transaction observes its own prior writes; separate transactions do not share connection state; nontransactional catalogs do not claim snapshots; metadata changes cannot alter types between accepted schema and streamed batches.
- Cache enabled globally still produces no query cache lookup/hit/store/revalidation/partition activity. Existing ordinary-table cache/filter behavior remains intact.
Prefer DuckDB sqllogictests for adapter behavior and Python tests for provider/protocol contracts. Use generated cross-language fixtures to check every SDK, not Python serialization alone. Full engine builds belong to the implementation milestones; this document itself changes no runtime.
Measure aggregate-only, same-catalog join+aggregate, ordered top-K, plain scans, unsupported mixed queries, and repeated prepared statements. Include both localhost/subprocess and realistic HTTP latency, and small/large result sets. Report total latency, preparation overhead, time to first batch, transferred rows/bytes, client/worker CPU and memory, and cleanup latency.
The primary success criterion is that accepted reductions transfer the final result, not all input rows, with identical semantics. Also quantify the extra metadata/preparation round trips and the regression budget for declined or small queries. Do not gate shipment on an invented universal speedup percentage; establish budgets from these measurements before default enablement.
The Python SDK and extension currently declare 2.1.0 under vgi.v2. The query capability
and new RPC target the next coordinated additive surface revision, 2.2.0, subject to release
coordination if another feature claims that revision first. Do not append them silently to
2.1.0 or rename the protocol to vgi.v2.2.
The framework enforces exact major/minor compatibility. Some catalog-item decoders tolerate absent optional fields by name, but the generated request/response validators also compare schema shape; this is not general cross-version interoperability. Update the SDKs, typed models, generated schemas, C++ protocol identifiers, and integration fixtures together. Capability absence means unsupported query execution only after surface-version compatibility is established. A rolling deployment needs matched endpoints/releases or an explicit version-routing strategy; retaining the same major protocol name alone does not make 2.1 and 2.2 peers interoperable.
Wire version, query-profile version, SDK package version, and DuckDB engine version remain independent. The feature belongs to the VGI 2 family even though its proposed wire release is 2.2.
Alternatives considered:
- Only a hidden SQL table function: sufficient for explicit execution, but lacks a first-class decline/result-contract decision for safe automatic substitution. Keep it as the data plane, not the entire protocol design.
- New prepare/execute/fetch/cancel wire family: offers control but duplicates existing VGI streaming, Arrow, authentication, cancellation, and transport work. Not needed for this release.
- Worker-selected arbitrary executor
FunctionInfo: flexible but exposes many irrelevant flags and permits split/filter/order combinations that weaken query guarantees. A fixed reserved executor has a much smaller conformance surface. - DuckDB binary AST/logical-plan serialization: tightly couples worker and client internals; unsuitable as VGI's language-neutral wire contract.
- Always require a new DuckDB remote-catalog hook: no longer the first choice now that a bound-plan exporter and optimizer-extension ownership can support a non-destructive attempt. Retain the hook as a conditional alternative with separate tests, not an assumed prerequisite.
- Export the final optimized plan without checking context: can ship folded observations from another snapshot or lose source provenance. Use the early-stage/snapshot gate instead. The new exporter reduces reconstruction work; it does not make arbitrary distributed plans safe.
- Substrait or a new engine-neutral relational IR immediately: possible future encodings, but much larger than the required read-query capability and does not eliminate type/function semantics or authorization problems. Leave encoding negotiation extensible.
- Automatic retry locally on any remote error: can duplicate execution, change snapshots, leak side effects through functions, or hide permissions and backend failures. Reject it.
Launch gates, not optional follow-up work: exact supported-type mappings; early-stage SQL export and safe decline ownership in the target DuckDB build; preserved required-filter/branch policy; logical-table authorization equivalence; ordered streaming under parallel client settings; process-safe execution ownership; transaction integration; no accidental result cache use; and coordinated wire-version/codegen validation.
Deferred design work: parameter binding; additional SQL/function/type profiles; remote writes; time travel; nested catalog views; cost-based placement; arbitrary partial subplans; query-result cache validators tied to data snapshots; split result streams with explicit global-order and partition semantics; and durable resumable execution with acknowledged batch IDs. None should be advertised through the initial capability before its contract and tests exist.
All local paths below are relative to the repository named; commit identities are recorded at the top so future API drift is visible.
| Repository / source | Relevant behavior |
|---|---|
DuckDB: src/include/duckdb/catalog/catalog.hpp |
Remote capabilities, execution hooks, support callbacks |
DuckDB: src/optimizer/remote_pushdown_optimizer.cpp |
Single-catalog analysis, finishing sites, catalog stripping, wrappers, CTE restrictions |
DuckDB: src/planner/planner.cpp, src/optimizer/optimizer.cpp |
Pre-binding invocation and optimizer gates |
DuckDB: src/include/duckdb/function/table_function.hpp |
Table-function binding, order and parallelism controls |
DuckDB: src/include/duckdb/optimizer/optimizer_extension.hpp, src/optimizer/optimizer.cpp |
Pre-optimizer callback ownership, ordering, and optimizer opt-outs |
DuckDB: src/planner/sql_export/README.md, src/include/duckdb/planner/logical_plan_sql_exporter.hpp |
Export contract, folded observations, experimental fragment limits, result bindings/types |
DuckDB: src/planner/sql_export/table_function_sql_export.cpp |
Source callbacks, logical-table reconstruction, and unsupported-source reasons |
DuckDB: src/include/duckdb/parser/parser.hpp, src/include/duckdb/common/types.hpp |
Explicit parser contexts and intrusive logical-type ownership |
DuckDB: src/planner/logical_operator_repeatability.cpp |
Conservative executor repeatability and ordinary table classification |
vgi-python: vgi/protocol.py |
Client capabilities, bind/init requests, TABLE state rehydration, wire versioning |
vgi-python: vgi/catalog/catalog_interface.py |
Attachment metadata, schema paths, function and scan contracts |
vgi-python: vgi/catalog/descriptors.py, vgi/worker.py |
Declarative catalog construction, function routing, attachment context |
vgi-python: vgi/transactor/server.py, vgi/transactor/_duckdb_compat.py |
Existing transaction-owner model, Arrow scans, fork-specific shared-transaction cursors |
vgi-python: vgi/codegen/_common.py, scripts/regen_generated.py |
Schema/type inventories and coordinated generation, including nested records and C# |
vgi-python: vgi/auth.py, vgi/client/client.py |
Effective identity/grant integration and early-close cleanup |
vgi: src/include/storage/vgi_catalog.hpp, src/storage/vgi_transaction.cpp |
Catalog state and transaction lifetime |
vgi: src/storage/vgi_catalog.cpp, src/vgi_catalog_api.cpp |
Bulk metadata seeds, etag/version adoption, and pooled catalog RPC validation |
vgi: src/include/vgi_catalog_metadata.hpp, src/include/vgi_rpc_client.hpp |
Attachment capability records and generated protocol routing |
vgi: src/storage/vgi_table_entry.cpp, src/storage/vgi_table_function_set.cpp |
Existing catalog scan binding and metadata |
vgi: src/vgi_function_connection.cpp, src/vgi_table_function_impl.cpp |
Bind/init transport, copies, ordering, result-cache integration |
vgi: src/vgi_batch_validation.cpp, src/vgi_cancel_dispatcher.cpp |
Reusable batch guards and off-thread stream teardown |
vgi: src/vgi_extension.cpp, src/vgi_multi_scan_rewriter.cpp |
Required-filter checks, branch rewriting, and per-database cache ownership |
vgi: src/vgi_settings.json, scripts/generate_settings.py, src/vgi_location_policy.cpp |
Generated setting validation and existing-attachment transport policy |
Related design documents: VGI 2.0 audit, filter-v2 specification, and DuckDB filter adapter. Query pushdown complements those scan-level contracts; it does not change their meaning.