@@ -427,26 +427,17 @@ py::dict DuckDBPyResult::FetchTF() {
427427 return result_dict;
428428}
429429
430- // Build a `SELECT * FROM <column data>` statement that scans `collection` in place, to be
431- // executed directly via ClientContext::PendingQuery(statement, ...).
432- //
433- // This deliberately avoids ClientContext::PendingQuery(relation, ...), whose
434- // RelationStatement constructor eagerly calls relation->GetQuery() and stringifies the
435- // ENTIRE ColumnDataCollection (formatting every value - an O(rows) pathology that costs
436- // >8s / 10M rows). The query string is never used for binding (Binder::Bind just calls
437- // relation->Bind), so executing the SelectStatement skips it entirely while keeping the
438- // zero-copy, parallel ColumnDataScan. The ColumnDataRef owns the collection (and the bound
439- // LogicalColumnDataGet takes it over), so the data stays alive for the result - important
440- // for the lazy StreamQueryResult path.
430+ // `SELECT * FROM <column data>` over `collection`, executed as a SelectStatement rather
431+ // than via PendingQuery(relation) - the latter's RelationStatement stringifies the whole
432+ // collection (O(rows)). The ColumnDataRef owns the collection, so it outlives the result
433+ // (needed by the lazy stream path).
441434static unique_ptr<SelectStatement> MakeColumnDataScanStatement (unique_ptr<ColumnDataCollection> collection,
442435 const vector<string> &names) {
443- // The binder rejects a ColumnDataRef with duplicate column names, so de-duplicate
444- // them (a, a -> a, a_1); callers restore the original output names on the result.
436+ // The binder rejects duplicate column names; callers restore the originals afterwards.
445437 auto deduplicated_names = names;
446438 QueryResult::DeduplicateColumns (deduplicated_names);
447439 auto table_ref = make_uniq<ColumnDataRef>(std::move (collection), std::move (deduplicated_names));
448- // Binding requires a set alias (BindingAlias::GetAlias asserts otherwise).
449- table_ref->alias = " materialized" ;
440+ table_ref->alias = " materialized" ; // binding asserts on an unset alias
450441 auto select_node = make_uniq<SelectNode>();
451442 select_node->select_list .push_back (make_uniq<StarExpression>());
452443 select_node->from_table = std::move (table_ref);
@@ -455,20 +446,14 @@ static unique_ptr<SelectStatement> MakeColumnDataScanStatement(unique_ptr<Column
455446 return select;
456447}
457448
449+ // Re-feed a materialized result through a PhysicalArrowCollector on the user's own context
450+ // (parallel conversion, correct Arrow settings) -> ArrowQueryResult.
458451void DuckDBPyResult::PromoteMaterializedToArrow (idx_t batch_size) {
459- // Only an already-materialized result (a ColumnDataCollection in memory) is pulled
460- // back through the engine. A lazy StreamQueryResult is converted directly instead -
461- // see FetchArrowTable - so we never materialize a lazy result just to re-feed it.
462452 D_ASSERT (result->type == QueryResultType::MATERIALIZED_RESULT );
463453 auto client_context = result->client_properties .client_context ;
464454 if (!client_context) {
465455 throw InternalException (" Cannot promote result to Arrow: the originating client context is gone" );
466456 }
467- // Re-run the in-memory result through the engine with a PhysicalArrowCollector on
468- // the user's own context. The ColumnDataScan over the collection is parallel, so the
469- // Arrow conversion runs in parallel, just like the fresh relational to_arrow_table
470- // path. Running on the user's context means the result carries the user's Arrow
471- // output settings automatically (no need to replicate them).
472457 auto context = client_context->shared_from_this ();
473458 auto &materialized = result->Cast <MaterializedQueryResult>();
474459 auto names = result->names ;
@@ -494,25 +479,19 @@ void DuckDBPyResult::PromoteMaterializedToArrow(idx_t batch_size) {
494479 if (new_result->HasError ()) {
495480 new_result->ThrowError ();
496481 }
497- // Re-binding the ColumnDataRef de-duplicates duplicate output column names
498- // (a, a -> a, a_1). Restore the originals so the Arrow output is unchanged.
499- new_result->names = std::move (names);
482+ new_result->names = std::move (names); // restore names de-duplicated by re-binding
500483 result = std::move (new_result);
501484}
502485
486+ // Re-feed a materialized result as a lazy stream on the user's own context. The
487+ // StreamQueryResult co-owns the context, so conversion survives `del conn` and runs under a
488+ // live transaction (geometry/extension correctness, #492).
503489void DuckDBPyResult::PromoteMaterializedToStream () {
504- // As above, only an already-materialized result is re-fed; a lazy StreamQueryResult
505- // is wrapped directly (it already has a live context).
506490 D_ASSERT (result->type == QueryResultType::MATERIALIZED_RESULT );
507491 auto client_context = result->client_properties .client_context ;
508492 if (!client_context) {
509493 throw InternalException (" Cannot promote result to an Arrow stream: the originating client context is gone" );
510494 }
511- // Re-run the materialized result as a lazy stream on the user's own context. The
512- // resulting StreamQueryResult co-owns that ClientContext (shared_ptr), so the stream
513- // - and the Arrow conversion it drives - survives `del conn` and runs under a live
514- // transaction (the geometry/extension correctness win, native #492 fix). The
515- // ColumnDataRef owns the collection, so the data outlives the temporary relation.
516495 auto context = client_context->shared_from_this ();
517496 auto &materialized = result->Cast <MaterializedQueryResult>();
518497 auto names = result->names ;
@@ -536,12 +515,8 @@ duckdb::pyarrow::Table DuckDBPyResult::FetchArrowTable(idx_t rows_per_batch, boo
536515 if (!result) {
537516 throw InvalidInputException (" There is no query result" );
538517 }
539- // Route by result type:
540- // - ARROW_RESULT: fresh to_arrow_table already ran a PhysicalArrowCollector.
541- // - MATERIALIZED_RESULT (rel.execute()): a CDC in memory - re-feed it through a
542- // PhysicalArrowCollector for parallel conversion (-> ARROW_RESULT below).
543- // - STREAM_RESULT (con.execute()): a lazy result with a live context - convert it
544- // directly, batch by batch. We never materialize a lazy result just to re-feed it.
518+ // ARROW_RESULT: fresh collector output. MATERIALIZED: re-feed for parallel conversion.
519+ // STREAM: a live result, converted directly below (never materialized to re-feed).
545520 if (result->type == QueryResultType::MATERIALIZED_RESULT ) {
546521 PromoteMaterializedToArrow (rows_per_batch);
547522 }
@@ -600,18 +575,13 @@ ArrowArrayStream DuckDBPyResult::FetchArrowArrayStream(idx_t rows_per_batch) {
600575 if (!result) {
601576 throw InvalidInputException (" There is no query result" );
602577 }
603- // The lazy Arrow surfaces (capsule, reader) need a context-owning StreamQueryResult.
604- // A pre-executed relation hands us a MaterializedQueryResult (a CDC) - re-feed it as a
605- // stream so the conversion gets a live, owned context (survives `del conn`, runs under
606- // a live txn). A StreamQueryResult (con.execute(), or a fresh streaming relation)
607- // already has a live context, so we wrap it directly - never materializing a lazy
608- // result.
578+ // Re-feed a materialized result to get a context-owning stream; a StreamQueryResult is
579+ // wrapped directly (already has a live context).
609580 if (result->type == QueryResultType::MATERIALIZED_RESULT ) {
610581 PromoteMaterializedToStream ();
611582 }
583+ // The wrapper is owned by the ArrowArrayStream's private_data (released with the stream).
612584 ResultArrowArrayStreamWrapper *result_stream = new ResultArrowArrayStreamWrapper (std::move (result), rows_per_batch);
613- // The 'result_stream' is part of the 'private_data' of the ArrowArrayStream and its lifetime is bound to that of
614- // the ArrowArrayStream.
615585 return result_stream->stream ;
616586}
617587
@@ -643,13 +613,7 @@ py::object DuckDBPyResult::FetchArrowCapsule(idx_t rows_per_batch) {
643613 if (!result) {
644614 throw InvalidInputException (" There is no query result" );
645615 }
646- // The capsule is a lazy streaming object backed by a StreamQueryResult, which
647- // owns its own ClientContext. The caller (DuckDBPyRelation) ensures `result` is
648- // a stream: a fresh relation streams on the user's connection; an already-
649- // executed result is re-fed via PromoteToDedicatedStream(). Because the stream
650- // owns its context, core's ResultArrowArrayStreamWrapper can lazily compute the
651- // schema and convert (incl. extension/geometry types under a live transaction)
652- // after the originating connection is gone - no eager schema caching needed.
616+ // Lazy streaming capsule backed by a context-owning stream (see FetchArrowArrayStream).
653617 auto inner_stream = FetchArrowArrayStream (rows_per_batch);
654618 auto stream = new ArrowArrayStream ();
655619 *stream = inner_stream;
0 commit comments