Skip to content

Close all eight open issues from the first external connector - #16

Merged
coccor merged 6 commits into
mainfrom
worktree-issues-batch
Aug 23, 2026
Merged

Close all eight open issues from the first external connector#16
coccor merged 6 commits into
mainfrom
worktree-issues-batch

Conversation

@coccor

@coccor coccor commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Closes all eight open issues — the report set from building Pz.Connector.DeltaLake, the first external connector against the ABI. Six commits, one per closed concern, each green on dotnet build + dotnet test and on scripts/verify-tool-install.sh.

The one real bug

#8 / #9pz.lock.json recorded bare file names, so the target framework and RID NuGetResolver had already chosen were gone by extraction time and PackageMaterializer re-found each name by prefix scan. Archive order decided which build landed on disk: a net472 assembly on a net10.0 host, an arm64 native library on x86-64. Both are valid files with the right names, so neither pz restore nor the lock file looked wrong.

Assets now carry the exact archive path alongside the name they materialize under (lock schema v2; a v1 lock is diagnosed as its version and regenerated, since it names no archive paths to upgrade from). Three consequences of the same root cause close with it:

  • transitive native/ is flattened into the connector package the way transitive lib/ already was — ConnectorLoadContext probes <lib>/../native and nowhere else;
  • two packages providing one file name is refused (PZ0325) instead of one silently overwriting the other by enumeration order;
  • native assets are selected through the RID graph, so a package shipping only linux-x64 is reachable from linux-musl-x64; a runtimes/ tree matching nothing the host can use is reported naming the RIDs it does ship.

RuntimeIdentifierGraph derives the portable graph from one OS-ancestry table plus one rule and is asserted RID by RID, including that macOS and Windows never reach linux.

Design calls made

#13partition_by was welded to calendar-token paths. The issue asks someone to decide between "make the option mean two things" and "every connector invents its own spelling". Neither: it has one meaning — the columns an output is partitioned by — and path: is what says who owns the layout. Calendar tokens mean pz renders it from one timestamp column (PathTemplating, unchanged); no tokens mean the destination records its own partitioning (ColumnPartitionedWrites, new and additive). DagCompiler keeps the checks it can make connector-agnostically and hands the capability question to the planner's PZ0314, exactly as the source-side refusal already works. The .ToString() type trap goes with it: PartitionColumns is the one place the option is parsed.

#14 — the project-directory anchor was matched by connector NAME, in six near-identical copies. It is declared now: a builtin in ProjectDirectoryAnchor.BuiltinAnchoredConnectors, a package connector via "projectDirectoryAnchor": true in its pz.connector.json. Opt-in both ways, so the injected option can never trip a ConnectionConfigSchema's additionalProperties: false. Manifests are read straight off disk — the anchor is applied before the connector registry exists. The six copies collapse to one implementation split across the two layers that own its halves.

ABI additions (additive only)

#10OutputSpec.Attempt tells a sink which attempt at which write it is executing (Node, Run, Ordinal), so a sink whose destination can record a durable progress marker can skip work a previous attempt already committed. Stamped only past the native-copy branch: a COPY has no session to carry a marker, and TryGetNativeCopy must see the same spec the planner probed with.

Scope is stated on the type rather than implied: this makes a within-run retry effectively-once — the case duplicates actually come from, a commit that reached the destination and then failed to report back. It does not span runs, and the type says so, so nobody builds a cross-run dedupe on it. Cross-run would need an origin run id threaded through run_results.json and the SQL artifact schema; deliberately not in this PR.

#11 — four TestKit gaps. A native-only source could not satisfy the suite at all (every data-plane fact reads through PlanReadAsync); those facts skip for an INativeOnlySource now and a NativeScan_* group asserts what a native reader owes instead. ShouldRun(fact) gives per-fact granularity next to the all-or-nothing GateFact(), which is untouched. Commit_persists_all_written_batches sorts rather than assuming an order ReadCommittedAsync never promised.

The fourth is the one worth reading: Sink_does_not_retain_engine_owned_batch_instances could not catch what it exists to catch — reference inequality is satisfied unconditionally by any connector that reads its rows back out of a real destination. The handed batches' buffers are zeroed after WriteBatchAsync returns (the pool refill the ABI says has already happened) and committed content is asserted. Disposal moves after the assertions on purpose: handing the memory back outright makes a retaining connector's read undefined rather than wrong, and "undefined" is how this passed connectors that were violating the protocol. RetainedBatchDetectionTests drives the fact against a deliberately retaining sink and requires the content assertion — not the identity one — to be what fails it.

Documentation

#12 / #15 — both are deliberate engine behaviour with good reasons, and both were discoverable only by reading pz's own source. They are stated on the ABI types, where a connector author is actually looking:

  • PzConnectorException.Message is trusted totally — verbatim into run_results.json, the NDJSON stream, and a retry_scheduled reason. A connector wrapping a third-party client owns redacting it, and owns knowing the shapes that client answers in. ErrorRedactionContractTests is the new acceptance class for that: it feeds a redactor an S3 XML 403, an Azure AuthorizationFailure, a connection string, and a name=value pair, and requires the credential gone and the diagnosis intact. Its self-test proves a name=value-only redactor — the one from the report — fails it.
  • The schema handed to BeginWriteAsync has every field nullable regardless of source, so a sink's nullability guards are unreachable through pz and a green pz run is not coverage of them. Same for Float, absent from the v0 type matrix.

Site pages that need a matching PR in pz-site

  • CLI / lock reference: pz.lock.json is schema v2 and records per-asset archive paths.
  • Connector authoring: PzConnectorException.Message trust boundary; the all-nullable write schema; pz.connector.json's projectDirectoryAnchor; ConnectorCapabilities.ColumnPartitionedWrites; OutputSpec.Attempt; ErrorRedactionContractTests.
  • connections.yml reference: partition_by: takes a column name or a list, and no longer requires calendar tokens in path:.
  • Delivery guarantees: attempt identity, and precisely what it does and does not change for append.

Verification

dotnet build Pz.slnx -c Release — 0 warnings. PZ_TESTS_OFFLINE=1 dotnet test Pz.slnx -c Release0 failures across all 21 test projects, exit 0 (docker suites ran; nothing skipped for docker on this machine). scripts/verify-tool-install.sh — PASS: pack → tool install → offline init → offline run --all.

New error code: PZ0325 (PackageAssetCollision). New capability: ColumnPartitionedWrites.

One behaviour change worth flagging in review

Two resolved packages providing the same lib/ or native/ file name is now a hard restore failure (PZ0325) rather than a silent overwrite. That is what #8 asks for and it matches the no-silent-failures rule, but it can turn a project that "worked" into one that reports a real ambiguity.

coccor added 6 commits August 23, 2026 20:21
…s first match

pz.lock.json recorded bare file names, so the target framework and RID
NuGetResolver had already selected were gone by extraction time and
PackageMaterializer re-found each name by a prefix scan. Archive order then
decided which build landed on disk: a net472 assembly on a net10.0 host, an
arm64 native library on x86-64 — both valid files with the right names, so
neither restore nor the lock file looked wrong.

Assets now carry the exact archive path alongside the name they materialize
under (lock schema v2; a v1 lock is diagnosed as its version and regenerated,
since it names no archive paths to upgrade from). Three consequences of the
same root cause close with it:

- transitive native/ assets are flattened into the connector package the way
  transitive lib/ already was, because ConnectorLoadContext probes
  <lib>/../native and nowhere else;
- two packages providing one file name is refused (PZ0325) instead of one
  silently overwriting the other by enumeration order;
- native assets are selected through the RID graph rather than an exact
  runtimes/{rid}/ prefix, so a package shipping only linux-x64 is reachable
  from linux-musl-x64; a runtimes/ tree matching nothing the host can use is
  reported naming the RIDs it does ship.

Closes #8. Closes #9.
The acceptance suite is the contract a connector proves itself against, and
subclassing it from outside this repository found four places where it could
not express, or did not actually assert, what it claims.

- A native-only source could not satisfy the suite at all: every data-plane
  fact reads through PlanReadAsync, which such a connector is defined to
  refuse. Those facts now skip for an INativeOnlySource, and a NativeScan_*
  group asserts what a native reader owes instead — a scan is offered, asking
  twice is free and gives the same answer, its Mechanism carries no location,
  a schema is still declared, and the universal path is refused permanently.
  The suite deliberately does not execute the fragment: that would put DuckDB
  into every connector's test dependencies.

- GateFact() is all-or-nothing and learns nothing about its caller, so a
  subclass needing to skip one fact had to skip them all. ShouldRun(fact) is
  the per-fact hook; GateFact() still runs first and still gates everything,
  so existing subclasses are untouched.

- Sink_does_not_retain_engine_owned_batch_instances could not catch what it
  exists to catch. Reference inequality is satisfied unconditionally by any
  connector that reads its rows back out of a real destination. The handed
  batches' buffers are now zeroed after WriteBatchAsync returns — the pool
  refill the ABI says has already happened — and the committed CONTENT is
  asserted, so a retaining connector commits recognisably wrong data instead
  of maybe-crashing. Disposal moves after the assertions on purpose: handing
  the memory back outright makes a retaining connector's read undefined
  rather than wrong, and "undefined" is how this passed connectors that were
  violating the protocol.

- Commit_persists_all_written_batches asserted rows in insertion order across
  the returned list, which ReadCommittedAsync never promises. It sorts now,
  and the hook says outright that order is not part of its contract.

Closes #11.
…scover

Both are deliberate engine behaviour with good reasons, and both were
discoverable only by reading pz's own source — which is not where a connector
author is looking.

`PzConnectorException.Message` is trusted TOTALLY: it reaches run_results.json,
the NDJSON event stream, and a retry_scheduled reason unredacted. A connector
wrapping a third-party client therefore owns redacting it, and owns knowing the
shapes that client actually answers in — a real object store's 403 carries the
access key id and the signing payload in XML elements, not name=value pairs, so
a redactor written against a local emulator can pass its own tests with the
redaction removed entirely. `ErrorRedactionContractTests` is the acceptance
class for that: it feeds a connector's redactor the shapes real services answer
in and requires the credential gone and the diagnosis intact. The same trust
carries storage locations into artifacts, which is intended and now said.

The schema handed to `BeginWriteAsync` has every field nullable regardless of
the source, because DuckDB's Arrow export does not reliably carry NOT NULL
through — so a sink's nullability guards are unreachable through pz, and a
green `pz run` is not coverage of them. Same for Float, which is not in the v0
type matrix at all.

Closes #12. Closes #15.
`base_dir = <projectDir>` was injected by matching the connector NAME against
"localfiles" or "sqlite", in six near-identical copies across the verbs and the
MCP handlers. Nothing reached a connector loaded from a package, so a relative
`root:`/`path:` in its config could only resolve against the process working
directory — the same project writing to a different place depending on where
`pz` was invoked from, which is the kind of failure found a week later.

Who gets an anchor is now declared. A builtin declares itself in
ProjectDirectoryAnchor.BuiltinAnchoredConnectors; a package connector declares
`"projectDirectoryAnchor": true` in its pz.connector.json. Opt-in both ways: a
connector that says nothing receives nothing, so the injected option can never
trip a ConnectionConfigSchema's additionalProperties: false — and a connector
that opts in declares `base_dir` in that schema, as the builtins already do.

Manifests are read straight off disk, never through a loaded assembly: the
anchor is applied to the project before the connector registry exists, and
pz.connector.json is exactly the file that answers this without an
AssemblyLoadContext. Unrestored packages contribute nothing and fail nothing; a
broken manifest is left for ConnectorHost to report with its own code and hint.

The six copies collapse to one implementation split across the two layers that
own its halves — Pz.Core does the injection over PzProject, Pz.PackageManagement
reads the manifests.

Closes #14.
Nothing reaching a sink distinguished a first run from a retry of the same
node, so a sink writing to a store that HAS a transaction or idempotency
primitive could not use it — which is what keeps `append` at at-least-once for
every such sink.

OutputSpec.Attempt carries that identity, additive in the shape of the Keys and
MaxTextLengths precedents: every existing `new OutputSpec(...)` compiles and
every existing sink behaves exactly as before. Node is pz's content-addressed
node id, Run is the run, Ordinal counts attempts from 1 — so a sink that
records the identity in the same transaction as the data can skip work a
previous attempt already committed.

It is stamped only past the native-copy branch: a COPY has no session to carry
a progress marker, and TryGetNativeCopy must see the same spec the planner
probed with, or a plan could disagree with the run it produced.

Scope is stated on the type rather than implied. This makes a within-run retry
effectively-once for a capable sink — which is the case duplicates actually
come from, a commit that reached the destination and then failed to report
back. It does NOT span runs: `pz retry` is a new run with a new Run value, so
append stays at-least-once across runs and nothing here should be used to build
a cross-run dedupe. (The engine already narrows that window itself: retry
carries forward the sinks it observed commit.)

Closes #10.
…s out

`partition_by:` was welded to calendar-token path templating: PZ0219 required
tokens in `path:` and treated the option as ONE column whose value substitutes
them. A format that partitions declaratively — Delta, Iceberg, Hive-layout
parquet — records its partition columns in its own metadata and produces the
directories itself, so it has no templated path to route into and could not use
the option at all.

One meaning now: partition_by names the columns an output is partitioned by.
What that produces is the destination's business, and `path:` is what says who
owns the layout — calendar tokens mean pz renders it from one timestamp column
(PathTemplating, unchanged), no tokens mean the destination records its own
partitioning (ColumnPartitionedWrites, new and additive). DagCompiler keeps the
checks it can make connector-agnostically and hands the capability question to
the planner's PZ0314, exactly as the source-side templating refusal already
works.

The type trap behind the same gate goes with it. The presence check was
`.ToString() is { Length: > 0 }`, so a YAML list passed it while meaning
something no downstream code expects: a single column named
System.Collections.Generic.List`1[System.Object]. PartitionColumns is the one
place the option is parsed — a scalar or a sequence, with a coded refusal for
anything else — so no connector re-derives it and none can hit that shape.

Closes #13.
@coccor
coccor merged commit 6fb3fae into main Aug 23, 2026
3 checks passed
@coccor
coccor deleted the worktree-issues-batch branch August 23, 2026 20:37
coccor added a commit to PipelineZ/pz-connector-deltalake that referenced this pull request Aug 24, 2026
Pre-1.0, breaking changes between connector/pz versions don't need a
permanent write-up. Strips the pz 0.2.2-vs-0.3.0 migration narrative
(package materializer bug, partition_by gating, PZ0219/PZ0321,
PipelineZ/pz#16) from installing.md, limitations.md, troubleshooting.md,
compatibility.md, reference/write.md, how-to/partitioned-tables.md,
how-to/tune-a-slow-merge.md, and README.md, and corrects a few tables
that were still describing the old broken state as current. Docs now
just state current requirements (pz 0.3.0+) and how to use the
connector.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant