fix(controlplane): stream COPY FROM STDIN bytes to remote workers#552
Conversation
In remote-worker (multitenant K8s) mode the control plane and worker
live in different pods with no shared volume. The existing COPY FROM
STDIN handler spooled CSV bytes into the control plane's local /tmp
and then issued `COPY ... FROM '/tmp/<spool>'` to the executor; the
worker tried to open that path against its own filesystem and failed
with "IO Error: No files found that match the pattern". Every Fivetran
sync against MTCP has been failing on this path.
Add an optional sqlcore.CopyFromStdinExecutor capability the wire
handler dispatches to when the executor is remote. The Flight executor
implements it by:
- Opening a Flight DoPut stream with a custom FlightDescriptor
(PATH=["duckgres-copy-from-stdin"], Cmd=COPY-SQL-template).
- Streaming CSV bytes in 1 MiB FlightData frames from the wire
CopyData reader straight through to the worker (no double-spool
on the CP side).
- Reading the standard DoPutUpdateResult for rows-affected.
The worker dispatches the custom descriptor before the standard
Flight SQL router via a thin DoPut interceptor on customActionServer.
A buffered FlightData adapter replays the first frame so unmatched
streams still flow into the standard handler unchanged.
The COPY SQL template carries a `__DUCKGRES_COPY_PATH__` placeholder
that the worker substitutes with its own tempfile path before
executing — the rest of BuildDuckDBCopyFromSQL stays on the CP.
Tests:
- flightclient: CP-side streamer sends descriptor + multi-frame
payload, parses RecordCount.
- duckdbservice: descriptor matcher; full doCopyFromStdin against
an in-memory DuckDB session round-trips three rows; rejects
missing placeholder; rejects missing session metadata.
Local executors (standalone / process backend) don't implement the
optional interface, so they keep using the existing local-tempfile
path unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
727c634 to
3a6e6e4
Compare
|
Self-review pass found a correctness bug and a couple of cosmetic issues; pushed a fix-up: Correctness — wire-level COPY cancellation could partially commit Fix: reader now returns a sentinel Regression test — Cosmetic — Removed unused
|
TL;DR
COPY FROM STDINfrom any client (Fivetran, dbt, psql\copy, etc.) is broken on the multitenant K8s control plane and has been since that backend shipped. Every COPY fails with:This PR fixes it by streaming the CSV bytes through Flight DoPut to the worker pod instead of relying on a shared filesystem.
Root cause (kept in case you skipped #551)
server/conn.go handleCopyInspooled CSV bytes into the control plane pod's local/tmp, then sentCOPY <table> FROM '/tmp/<spool>' (...)to the executor. In standalone and process-backend topologies the worker shares that filesystem; in remote-worker mode the worker is a separate pod (CP only mounts/app/certs, workers have an independent/dataemptyDir) so the path resolves to nothing on the worker.server/flightclient/flight_executor.go:269already flagged this — it explicitly errors out ofConnContextwith "use batched INSERT for COPY FROM" — buthandleCopyInhad no Flight-aware branch.The fix
Add an optional capability
sqlcore.CopyFromStdinExecutorthat the wire handler delegates to when the executor implements it. The Flight executor does; standalone and process-backend executors don't (they keep the existing local-tempfile path unchanged).Wire layout (CP → worker, on a single Flight DoPut stream)
The CP builds the COPY SQL with
BuildDuckDBCopyFromSQLas before, except the path is the placeholder. The worker writes the streamed bytes to its own/tmp, replaces the placeholder with the worker-local path, then runs the COPY againstsession.Conn.Worker dispatch
customActionServer.DoPutpeeks at the first FlightData frame and routes:IsCopyFromStdinDescriptor→doCopyFromStdinhandles the rest of the stream.prebufferedDoPutServeradapter that replays the first frame so the standard handler sees the full stream.This means existing
CommandStatementUpdatetraffic continues unchanged. The only new wire interaction is gated on a descriptor type that no other client produces.CP wire reader
copyDataWireReaderinserver/conn.goadapts the wireMsgCopyData/MsgCopyDone/MsgCopyFailstream into anio.Reader. CopyDone surfaces as cleanio.EOF; CopyFail and unexpected messages set sticky flags the caller checks after the streamer returns. Avoids any double-buffering on the CP — bytes go wire → io.Reader → DoPut frame → worker tempfile.Files
CopyFromStdinExecutoroptional capability.(*FlightExecutor).CopyFromStdin+ descriptor / placeholder constants.doCopyFromStdinandIsCopyFromStdinDescriptor.DoPutinterceptor oncustomActionServerandprebufferedDoPutServeradapter.handleCopyIndelegates tohandleCopyInRemoteStreamingwhen the executor implements the capability; newcopyDataWireReader.Tests
server/flightclient/copyfromstdin_test.go— fake gRPC server, asserts CP sends correct descriptor (path, COPY SQL inCmd), splits the payload into multiple frames for >1 MiB inputs, parses theDoPutUpdateResult.RecordCountfrom the response.duckdbservice/copy_from_stdin_test.go:IsCopyFromStdinDescriptormatcher coverage (nil, wrong type, wrong path, correct).doCopyFromStdinend-to-end against an in-memory DuckDB session: ingests a 3-row CSV split across two FlightData frames, runs the COPY, assertsDoPutUpdateResult.RecordCount == 3and that rows actually landed in the table.go test ./...andgo test -tags kubernetes ./...are green (the two pre-existingcontrolplane/adminfailures are missing-docker-composeon my laptop, unrelated).Test plan (post-merge)
posthog_data_import → billing_*. Confirm rows land in DuckLake-backed tables and the worker-side/tmp/duckgres-worker-copy-*.csvfiles come and go.psql \copy ... FROM STDINagainst a remote-worker session.duckgres-copy-*.csvpath, same logs).Related
🤖 Generated with Claude Code