Skip to content

feat: allow extension DataSinks to decode via a per-type registry - #26807

Draft
gabotechs wants to merge 2 commits into
apache:mainfrom
gabotechs:codex/datasink-proto-registry
Draft

gabotechs wants to merge 2 commits into
apache:mainfrom
gabotechs:codex/datasink-proto-registry

Conversation

@gabotechs

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Stacked on #26555; merge that PR first. No separate issue.

The branch includes the rebased DataSource change. Sink-only diff. The upstream PR targets main because GitHub cannot use a fork branch as the base of an upstream PR.

Rationale for this change

Custom DataSink implementations can encode themselves through try_to_proto, but decoding still requires an application-owned PhysicalExtensionCodec. Sinks from independent libraries therefore need codec composition.

Give sinks the same session-scoped, per-type decoder registration as sources in #26555.

What changes are included in this PR?

  • Add ExtensionDataSinkFromProto, register_data_sink, data_sink_names, and extension_data_sink_node alongside the source APIs.
  • Register DataSinkExec as the shared extension-plan decoder. Its typed payload contains the sink name, sink bytes, and optional sort requirements; the existing extension-plan node carries exactly one input plan. DataFusion reconstructs the input, ordering, and execution-plan wrapper around the decoded sink.
  • Keep sink names separate from source, plan, and expression names. Registered-wrapper failures are final; an unregistered wrapper retains the existing plan-codec fallback. Built-in and existing codec-owned sinks retain their wire formats. No new top-level plan variant is added.

What is the testing strategy for this PR?

Five registration unit tests and twelve integration tests cover receiving-session state and actual writes after a binary round trip, input and ordering preservation, independent sink types, name isolation, invalid payloads/inputs/orderings, codec dispatch boundaries, legacy codecs, and JSON serialization (with the json feature).

Validation: 570 affected-crate tests and 14 doctests passed. Formatting, workspace Clippy with warnings denied, rustdoc with warnings denied, protobuf regeneration, and documentation formatting completed. The broader dev/rust_lint.sh suite could not start because /usr/bin/python3 lacks PyYAML.

Are there any user-facing changes?

New opt-in sink registration APIs behind the existing proto feature, documented in the 56.0.0 upgrade guide. Register sinks on every reader before writers emit the new wrapper payload. Existing built-in sink formats are unchanged.

AI-assisted implementation with Codex.

@gabotechs gabotechs added the api change Changes the API exposed to users of the crate label Oct 27, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation proto Related to proto crate datasource Changes to the datasource crate physical-plan Changes to the physical-plan crate labels Oct 27, 2026

@adriangb adriangb left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is stacked on #26555. The design points from #26555 (review) also apply here.

With the precursor #26824:

PhysicalPlanNode
├─ ...                   (other ExecutionPlan variants)
└─ data_sink: DataSinkExecNode {input, sort_order}
   └─ DataSinkNode (oneof)
      ├─ json / csv / parquet
      └─ extension {name, payload}
  • DataSinkExec encodes input and sort_order. The sink encodes only its own fields.
  • This PR then needs one ExtensionDataSinkFromProto trait with &[u8] input, a provided register_proto, and one decode arm.

Comment on lines +57 to +61
fn try_from_proto(
node: &PhysicalExtensionDataSinkNode,
ctx: &ExecutionPlanDecodeCtx<'_>,
) -> Result<Arc<dyn DataSink>>;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same point as in #26555: pass payload: &[u8]. sort_order belongs to DataSinkExec, not to the sink.

Suggested change
fn try_from_proto(
node: &PhysicalExtensionDataSinkNode,
ctx: &ExecutionPlanDecodeCtx<'_>,
) -> Result<Arc<dyn DataSink>>;
}
fn try_from_proto(
payload: &[u8],
ctx: &ExecutionPlanDecodeCtx<'_>,
) -> Result<Arc<dyn DataSink>>;

Comment on lines +84 to +95
pub fn extension_data_sink_node<T: ExtensionDataSinkFromProto>(
ctx: &ExecutionPlanEncodeCtx<'_>,
exec: &DataSinkExec,
payload: Vec<u8>,
) -> Result<PhysicalPlanNode> {
let sink = PhysicalExtensionDataSinkNode {
node: payload,
sink_name: Some(T::NAME.to_string()),
sort_order: exec.encode_sort_order(ctx)?,
};
ctx.extension_node::<DataSinkExec>(sink.encode_to_vec(), vec![exec.input()])
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sink receives &DataSinkExec only to encode the wrapper's input and sort order. That gives the sink access to its parent.

With DataSink::try_to_proto_sink -> DataSinkNode, DataSinkExec encodes input and sort_order, and this function can be removed.

Comment on lines +115 to +121
// The shared wrapper may remain registered if sink registration fails.
// This is safe: it still requires a registered sink decoder and rejects
// unknown sinks rather than decoding their payloads through a plan codec.
DataSinkExec::register_proto(registry)?;
registry
.register_decoder::<dyn DataSink, T, DataSinkDecoder>(T::NAME, T::try_from_proto)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as #26555: validate first, then register the wrapper.

Suggested change
// The shared wrapper may remain registered if sink registration fails.
// This is safe: it still requires a registered sink decoder and rejects
// unknown sinks rather than decoding their payloads through a plan codec.
DataSinkExec::register_proto(registry)?;
registry
.register_decoder::<dyn DataSink, T, DataSinkDecoder>(T::NAME, T::try_from_proto)
}
if T::NAME.is_empty() {
return plan_err!("extension DataSink name must not be empty");
}
registry
.register_decoder::<dyn DataSink, T, DataSinkDecoder>(T::NAME, T::try_from_proto)?;
DataSinkExec::register_proto(registry)
}


/// Every registered extension sink name, in arbitrary order.
/// Names registered for other extension kinds are not included.
pub fn data_sink_names(registry: &ProtoDecoderRegistry) -> impl Iterator<Item = &str> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as data_source_names: used outside tests only by the error message at L169.

Suggested change
pub fn data_sink_names(registry: &ProtoDecoderRegistry) -> impl Iterator<Item = &str> {
pub(crate) fn data_sink_names(registry: &ProtoDecoderRegistry) -> impl Iterator<Item = &str> {

Comment on lines +167 to +182
let mut names: Vec<&str> = registry
.as_ref()
.map(|registry| data_sink_names(registry).collect())
.unwrap_or_default();
names.sort_unstable();
let names = if names.is_empty() {
"none".to_string()
} else {
names.join(", ")
};
plan_err!(
"No decoder is registered for the extension DataSink '{name}'. Register \
the sink in the ProtoDecoderRegistry attached to the decoding session's \
SessionConfig. Registered extension data sinks: {names}"
)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This duplicates registry.rs L180–197 except for the noun. Put it in one shared helper, for example fn unknown_extension_err(kind: &str, name: &str, names: impl Iterator<Item = &str>) -> DataFusionError.

Comment on lines +38 to +39
pub mod registry;
pub mod sink_registry;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as #26555: keep these modules private. All items are also re-exported below, so this is a second public path to the same items.

Comment on lines +1073 to +1077
bytes node = 1;
// Namespaced sink type name. Once the DataSinkExec wrapper decoder is
// registered, sink lookup and decoding errors do not fall back to a plan codec.
optional string sink_name = 2;
PhysicalSortExprNodeCollection sort_order = 3;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sort_order belongs to DataSinkExec, not to the sink payload. In the precursor (#26824) it moves to DataSinkExecNode, and the sink message is ExtensionDataSinkNode { string name = 1; bytes payload = 2; }.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api change Changes the API exposed to users of the crate datasource Changes to the datasource crate documentation Improvements or additions to documentation physical-plan Changes to the physical-plan crate proto Related to proto crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants