Skip to content

fix: DuckDB and server-to-server architecture#145

Merged
Spelkington merged 11 commits into
mainfrom
integrate/adr-0024-milestone
Jul 9, 2026
Merged

fix: DuckDB and server-to-server architecture#145
Spelkington merged 11 commits into
mainfrom
integrate/adr-0024-milestone

Conversation

@Spelkington

Copy link
Copy Markdown
Contributor

No description provided.

Adds the byte-addressing seam to Core's cross-cutting capability
surface: ISupportsByteLocation resolves where an item's bytes live —
a ByteLocation closed sum over LocalFile / RemoteUri-with-access-
handoff — without loading rows, so a native-reading consumer (an
embedded engine, a bulk copier) can be pointed straight at storage.

- ComposedStorageAdapter implements the capability by delegating to
  its medium; a non-addressable medium fails as a typed value, never
  a silent fallback that materialises rows.
- IItem<T>.LocateBytes() demands the capability at wire-up, unwrapping
  a .Constrain() wrapper exactly as AsStream() does, and throws a loud
  error naming the item when the adapter cannot honor it.
- FileStorageMedium answers with the absolute file path; S3StorageMedium
  routes through the IS3Gateway seam — the credential owner — so the
  production gateway mints the s3:// URI plus endpoint/region/credential
  entries per call and the local stub hands back the backing file itself.
- Ships the ISupportsByteLocationLaws kit (addressability honesty,
  address stability, write-target addressability) with subclasses for
  the file medium, the composed adapter, and S3 over the local stub,
  plus targeted tests for the incapable-adapter and constrained paths.

Claude-Session: https://claude.ai/code/session_01DbyQtC1gFwbv9VAsyZ4AWQ
Adds the Flowthru.Extensions.DuckDB extension: a wide-transform step
wired between ordinary Parquet catalog items whose entire body is SQL
executed inside the embedded DuckDB engine. Rows never enter the CLR —
inputs bind as read_parquet views via the byte-location capability, the
result is verified against the output item's declared schema (DESCRIBE,
before any write), and written engine-side with COPY (FORMAT PARQUET).

Extension surface:
- AddDuckDbTransform intent verbs (1-input and multi-relation shapes)
  with DuckDbInputRelation bindings defaulting to item labels
- IDuckDbEngine + InProcessDuckDbEngine, registered by UseDuckDb() with
  options bound from Flowthru:DuckDb
- Engine registers as a cache-neutral (AffectsOutputs=false),
  capacity-constrained service dependency mirroring the Python worker;
  scheduler holds concurrent transforms to MaxConcurrentTransforms
- Typed failures: RuntimeError.SchemaMismatch for result-schema
  disagreement, DuckDbRuntimeError (FTDDB40xx) for remote-URI endpoints
  and engine errors — all as FlowIO error values, nothing thrown

Core (additive):
- IStepNode.DeclaredUncacheableReason seam +
  StepUncacheableReason.DeclaredByStep case: engine transforms opt out
  of caching loudly (SQL text isn't in the cache identity yet), visible
  everywhere uncacheable reasons surface

Parquet (additive fix):
- ParquetAdapter's DTO fast path now defers to the schema-mirroring
  runtime DTO when file column types disagree with TRow (external
  writers mark all columns optional; the fast path was a hard
  definition-level mismatch on any externally-written file)

Benchmark (category-gated, [Explicit]): 5M-row composite-key sort —
LINQ OrderBy 8.87s / 3.38GiB managed allocations vs DuckDB 0.39s /
99.6KiB (23x, row-count-independent CLR allocations).

Claude-Session: https://claude.ai/code/session_01DbyQtC1gFwbv9VAsyZ4AWQ
Schema-validated SQL per ADR-0024's v1 relational surface: every
AddDuckDbTransform step is now checked before any step runs — empty
in-memory tables are built from the declared input record schemas
(named per the step's relation bindings), the transform SQL is
DESCRIBEd against them (binding without executing), and the described
result schema is verified against the declared output schema. Zero I/O
against real data; failures name the step, the relation binding, and
the offending column(s), and aggregate applicatively with all other
pre-flight errors.

Core (the ADR-0021 "symmetric follow-up"):
- IFlowValidationHook gains MinimumDepth self-classification on the
  ValidationDepth ladder (default Shallow), mirroring
  IRegistrationValidationHook; PreFlightPipeline admits
  hermetic-classified flow hooks at Hermetic scope.
- FlowthruService now merges DI-registered IFlowValidationHooks with
  builder-registered ones and filters by the run's depth — the plural
  DI surface extensions already use (Python) finally reaches the
  hosted pre-flight path.
- ValidationDepth.Hermetic / PreFlightScope.Hermetic docs sharpened:
  the promise is "no external reach", with an explicit carve-out for
  process-local computation (embedded in-memory engine as a
  metadata-only type-checker) so the hermetic classification of this
  check stays honest rather than silently stretched.

Extension (Flowthru.Extensions.DuckDB):
- DuckDbSqlSchemaCheck: the shared hermetic check core, reusing the
  runtime's DuckDbTypeMap/DuckDbSchemaVerifier and SQL-text discipline
  (hoisted into DuckDbSql) so pre-flight binds byte-identical SQL.
- DuckDbPreFlightError (FTDDB3001 preparation failure / FTDDB3002
  result-schema mismatch / FTDDB3003 unmodellable input schema),
  wrapped through PreFlightError.External.
- DuckDbTransformValidationHook (HookId "duckdb.sql-schema",
  MinimumDepth Hermetic), registered by UseDuckDb() via
  TryAddEnumerable.
- Design-time surfaces: DuckDbTransformStep.Validate() now runs the
  real check (so FUnitContext.Validate(step) fails a unit test on a
  schema-breaking SQL edit), plus BuiltFlow.ValidateDuckDbTransforms()
  for whole-flow test assertions. The engine never calls Validate() on
  the run path — the happy path pays only the pre-flight check itself.

Claude-Session: https://claude.ai/code/session_01DbyQtC1gFwbv9VAsyZ4AWQ
Teach the engine transform to run S3-Parquet -> S3-Parquet entirely
in-engine, closing the FTDDB4001 gap for s3:// URIs:

- InProcessDuckDbEngine handles ByteLocation.RemoteUri for s3://
  endpoints: loads httpfs (INSTALL on first use when downloads are
  allowed), mints one temporary DuckDB secret per distinct object from
  the gateway's access handoff, each SCOPEd to exactly that object's
  URI so multi-credential inputs never bleed into each other, then
  read_parquet('s3://...') / COPY ... TO 's3://...' with no CLR
  buffering and no CLR rows.
- Credential material lives only in the transform's private in-memory
  connection; engine error text is scrubbed of credential values
  before surfacing (DuckDB errors echo the offending SQL).
- httpfs is not statically linked in DuckDB.NET.Data.Full: new
  DuckDbEngineOptions.ExtensionDirectory (offline pre-provisioning)
  and AllowExtensionDownload (default true; false makes a missing
  httpfs the typed FTDDB4003 failure and disables DuckDB autoinstall
  for the connection).
- Non-s3 remote schemes keep the typed FTDDB4001 error with an
  honestly narrowed message naming the offending scheme.
- DuckDbBoundRelation/DuckDbTransformRequest now carry ByteLocation
  endpoints instead of pre-collapsed local paths.
- README: S3 support section (gateway-minted, per-call,
  connection-scoped credential flow), httpfs loading story, refreshed
  limitations.

Tests: offline secret-SQL planning/redaction units, scheme rejection
(step + engine-defensive), typed FTDDB4003 failure mode, s3:read
conflict-key inheritance over S3-backed endpoints (ADR-0023), an
always-on offline tier over the file-backed S3 stub, and an
env-gated (TestCapabilities.AwsS3) integration tier covering S3->S3,
mixed local+S3 inputs, per-endpoint secret scoping, and the
zero-CLR-rows guarantee over S3 — verified green against a real MinIO.

Claude-Session: https://claude.ai/code/session_01DbyQtC1gFwbv9VAsyZ4AWQ
Engine-delegated transform steps are now first-class cacheable, with
the query in the key, per ADR-0024's cache-identity decision.

Core (general seam, no DuckDB coupling):
- IStepNode.DeclaredCacheIdentity: a defaulted member (following the
  DeclaredUncacheableReason precedent) through which a step declares
  the output-affecting wire-up data missing from CodeVersion — an
  opaque, ordinal-stable token folded into the composite fingerprint
  wherever code version + input fingerprints combine (CachePlanBuilder
  phase 3 and CacheManifestStore's post-run twin).
- ComposeStepFingerprint gains the declared segment; a null identity
  composes byte-identically to the pre-seam shape, so existing
  manifests stay valid for ordinary steps (no schema bump).
- The post-run twin now also mirrors the DeclaredUncacheableReason
  opt-out, keeping its eligibility a true mirror of pre-flight.
- DeclaredUncacheableReason/DeclaredByStep stay public surface; docs
  now route steps whose wire-up data CAN be fingerprinted to the new
  seam and reserve the opt-out for data that can't.

DuckDB implements it (stopgap removed):
- DeclaredCacheIdentity = SHA-256 of the exact SQL bytes (no
  normalization), the engine version, the relation-name -> item-label
  bindings (same SQL over reordered bindings reads different data),
  and the output-affecting write options (compression, row-group size
  both change the produced file's bytes). Engine tuning (memory,
  threads, concurrency) is deliberately excluded.
- CodeVersion = the extension assembly version (the transform
  machinery's identity), satisfying cache-plan eligibility.
- IDuckDbEngine.EngineVersion: the engine is the authority on its own
  version; InProcessDuckDbEngine probes DuckDBConnection.ServerVersion
  on a throwaway in-memory connection once per process, degrading to a
  per-process-unique sentinel (never a hit, never a throw) if the
  native library can't load.
- The DeclaredByStep uncacheable stopgap from #135 is gone; engine
  steps no longer appear in uncacheable reporting.

Tests: composite back-compat pin + declared-identity hit/stale/cascade
at the core plan level; DuckDB identity axes (SQL bytes, engine
version via fake, options, bindings, tuning exclusion) plus
end-to-end hit, per-axis invalidation (engine bump via a
version-spoofing wrapper over the real engine), and
downstream-of-engine-step cascade through a real IFlowthruService
with a file-backed manifest.

Claude-Session: https://claude.ai/code/session_01DbyQtC1gFwbv9VAsyZ4AWQ
Adds the medium-agnostic bulk-transfer surface from the transfer half of
the engine-delegated-transforms design, scoped to the streaming rung:

- ISupportsBulkExport / ISupportsBulkImport capability pair in Core's
  cross-cutting storage surface, so any two extensions can pair on
  provider + wire format without referencing each other, plus
  IItem.TryGetBulkExport/TryGetBulkImport discovery accessors.
- ISupportsStreamingSink<TRow>, the write-side sibling of
  ISupportsStreamingView, so a target item can open the batch sink the
  streaming rung drives.
- AddBulkTransfer(source, target, options): an intent verb emitting an
  on-DAG identity step between two endpoint items (the AddBulkLoad
  mechanics), inheriting scheduling, caching, pre-flight, and the
  conflict keys of both endpoints.
- Pre-flight rung negotiation (zero-I/O, runs at every scope including
  Hermetic): probes both endpoints' capabilities, checks pair
  compatibility, selects a rung, and reports the selection - including
  the downgrade rationale - in the run's validation output. A pairing
  with no executable rung fails pre-flight with the new
  PreFlightError.BulkTransferRungUnavailable (FT3008).
- BulkTransferOptions.RequireNative: an unavailable native path becomes
  a pre-flight error; always errors in this slice since the native
  rung's execution machinery lands in a later issue.
- FUnitContext.NegotiateTransfer so step tests can assert the reported
  rung without building a flow.
- StreamingItem now surfaces its origin's ServiceDependencies, so
  streaming views inherit conflict keys consistently.

Claude-Session: https://claude.ai/code/session_01DbyQtC1gFwbv9VAsyZ4AWQ
Flip the native rung from "known but unavailable" to executable. A
homogeneous Postgres pair now negotiates BulkTransferRung.Native at
pre-flight (visibly, naming the postgresql/pgcopy-binary pairing) and
executes as a raw binary COPY byte passthrough — no CLR rows.
Heterogeneous pairs still take the streaming fallback visibly, and
RequireNative passes for matched pairs and errors otherwise.

Core:
- BulkImportChannel (Stream + CompleteAsync): commit only on explicit
  completion; dispose-without-complete is the abort signal, so a torn
  transfer can never commit. Mirrors the IFlowSink discipline.
- BulkTransferBytePump: bounded-buffer (80 KB), cancellation-aware pump
  disposing both channels on every path, preserving typed errors.
- Negotiation selects Native for matched capability pairs; endpoint
  items execute the rung by re-probing the same capability resolvers
  negotiation used.

Flowthru.Extensions.EFCore.Npgsql (new package):
- NpgsqlCopyStorageAdapter<T>: EFCore adapter decorator claiming
  postgresql/pgcopy-binary, exporting via COPY TO STDOUT and importing
  via COPY FROM STDIN (FORMAT BINARY) on Npgsql's raw binary COPY API
  (no EFCore.BulkExtensions — #129). Table, schema, and column list
  resolve from the EF model; non-Npgsql providers fail at construction.
- Load semantics are explicit: NpgsqlBulkImportMode.Replace (default,
  transactional TRUNCATE + load) or Append; the import runs in one
  transaction, so a failed transfer rolls the target back untouched.
- Streaming view + transactional streaming sink so mixed pairings
  execute the fallback rung against Postgres endpoints.
- .NpgsqlTable<TRow, TContext>() catalog builder.

Tests: negotiation/pump/adapter tiers run offline; a Docker-gated
Postgres tier (FLOWTHRU_PG_TEST_CONNSTRING escape hatch for external
servers) covers both rungs end-to-end through the same AddBulkTransfer
wiring plus rollback on mid-COPY failure; a category-gated benchmark
measured the native rung 19.6x faster than streaming on 200k rows.

Claude-Session: https://claude.ai/code/session_01DbyQtC1gFwbv9VAsyZ4AWQ
…nsfer

Records the architecture decision consolidating #126/#127: wide
transforms and homogeneous transfers execute outside the CLR as
delegated arrows between ordinary typed items. Adds the wide-vs-narrow
transform vocabulary to the Extension Developer glossary.

Claude-Session: https://claude.ai/code/session_01DbyQtC1gFwbv9VAsyZ4AWQ
The bulk-transfer rung pre-flight code from the AddBulkTransfer work
was emitted from FlowthruDiagnosticCodes but never added to the
diagnostic-ID registry; the tests:_test:diagnostic-id-registration
check catches exactly this.

Claude-Session: https://claude.ai/code/session_01DbyQtC1gFwbv9VAsyZ4AWQ
…ement

The restored step-shape hook compared C# type names positionally
against decorator-declared names, which cannot succeed for byte[]
artifact or directory payloads — no decorator spelling matches
'Byte[]'. Name agreement is now enforced only at positions whose
underlying type carries [FlowthruSchema]; descriptive names for
artifact payloads remain FTPY2007's design-time concern. Arity
checks are unchanged.

Surfaced by FlowthruCoverage's heatmap/icicle steps, the first
bytes-output Python steps to run under the revived hook.

Claude-Session: https://claude.ai/code/session_01DbyQtC1gFwbv9VAsyZ4AWQ
@Spelkington Spelkington merged commit 8e40082 into main Jul 9, 2026
2 checks passed
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