feat: writable external tables (INSERT/LOAD into stage files) - #24889
Conversation
Make external tables writable when created with a WRITE_FILE_PATTERN option (a strftime template with MO extensions %nN = n random digits and %U = UUID, resolving to a stage:// path). Such tables accept INSERT ... SELECT and LOAD, writing CSV or JSONLine files into the stage; each parallel pipeline writes one file. Reads, UPDATE/DELETE, and read-only external tables are unchanged. - pkg/sql/colexec/externalwrite: strftime expander, ExternalWriter with a fileservice streaming sink, CSV/JSONLine encoders (const-vector aware, emit only the declared columns). - insert operator: third write mode ToExternal/insert_external alongside ToWriteS3/insert_table; write config carried on the Go InsertCtx (no proto change), built in compile from TableDef.Createsql. - compile: compileInsert routes external-write nodes to one writer op per source scope (no S3 merge/shuffle). - planner: minimal external insert plan (build_insert/build_load); op-aware checkTableType allows writable-external for insert; initInsertStmt Pkey guard; modern DML binder defers external targets to the legacy path. - DDL: validate WRITE_FILE_PATTERN (stage:// + csv/jsonline + parseable) and accept it in the read-side option validators. - docs/design + BVT case test/distributed/cases/stage/writable_external_table. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Qodo reviews are paused for this user.Troubleshooting steps vary by plan Learn more → On a Teams plan? Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center? |
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Review round 2 (at 2f3365c) — 10 inline findings posted, focused on the code added across the fix rounds and on test coverage. Several were verified empirically against the actual Severity picture: three P1s are silent-corruption bugs in validator/writer gaps (multi-char terminator boundary overlap — Adjacent issues found but not filed inline (pre-existing, beyond this PR's scope — listing for visibility):
|
XuPeng-SH
left a comment
There was a problem hiding this comment.
I rechecked the latest head. The CSV option round-trip work looks much better, but one substantive issue still remains.
SHOW CREATE TABLE still drops the external table's read FILEPATH, so writable external tables are not actually recreatable from their own DDL.
formatInfileExternalOptionsForShowCreate() still hardcodes filepath := "" for non-Hive external tables and only preserves param.Filepath when HivePartitioning is true (pkg/sql/plan/build_show_util.go:960-975). The new BVT output still shows this exact problem on the writable-table cases, e.g. ext_csv and t_all both emit INFILE{'FILEPATH'='', ... 'WRITE_FILE_PATTERN'=...} (test/distributed/cases/stage/writable_external_table.result:13-15, test/distributed/cases/stage/writable_external_csv_options.result:260-262).
That means the shown DDL does not recreate the original table semantics: it preserves the write pattern, but loses the read glob. For writable external tables this is not just cosmetic, because SHOW CREATE TABLE is supposed to be re-executable and MatrixOne's snapshot/PITR/restore paths fetch table DDL via show create table (pkg/frontend/snapshot_restore_with_ts.go:252-268, pkg/frontend/pitr.go:1583-1592, pkg/frontend/snapshot.go:2072-2080). Replaying the downgraded DDL can silently turn a readable writable-external table into one with no read path at all.
I think this still needs to be fixed before approval: preserve the external table's FILEPATH in SHOW CREATE TABLE / restoreDDL for these writable tables, not only the WRITE_FILE_PATTERN.
…overage) Corruption fixes (several verified against the real csvparser): - needsEnclosure also encloses a value whose suffix is a proper prefix of a multi-char field terminator: the reader matches the terminator across the value boundary, so (10,5) with TERMINATED BY '00' read back as (1,5). - The escape==enclosure conflict is rejected for the DEFAULT backslash escape too (ENCLOSED BY '\' previously validated and double-unescaped on read), and control characters cannot be the escape. - jsonline only accepts LINES TERMINATED BY '\n'/'\r\n': JSON strings have no enclosure, so a printable terminator inside a value split records. - addEscape rewrites CR as E+'r': the reader strips one trailing CR per record even from enclosed fields, so values ending in CR were truncated. With ESCAPED BY '' a trailing CR in the last column is rejected instead. - Values no encoding can round-trip now error instead of corrupting: a string of exactly \N under a non-default/disabled escape (the reader null-matches enclosed fields outside the backslash flavor) and always under jsonline (JsonNull compare post-decode); invalid UTF-8 strings under jsonline (the reader rewrites such bytes to U+FFFD). - enum labels are enclosed (a bare NULL token read back as SQL NULL); the reader no longer TrimSpaces bit fields (raw bytes: 'A ' lost its space, whitespace-only values became NULL) — bit 32/13/10 now round-trip. - %nN needs n >= 6 to qualify as the uniqueness directive (%1N has 10 outcomes; parallel writers collided by pigeonhole). Statement-scoped config drift: - The writer timestamp is re-resolved from defines.StartTS at Prepare and at remote-run encode: prepared EXECUTEs reuse the cached Compile, so the config value froze at the first execution and a daily EXECUTE kept writing into day-1's %Y%m%d directory. - The session time zone travels to remote CNs via new pipeline.Insert fields (external_tz_name + offset-at-statement fallback): the generic session codec round-trips zones as a year-1 LMT fixed offset, shifting rendered TIMESTAMPs by minutes on remote pipelines. - TRUNCATE on a writable external table errors instead of reporting success while the stage files survive. Coverage: - Unit tests: insert operator external path with a mock writer (NOT NULL matrix incl. const-null and the NotNull-only flag; Reset/Free must Abort, never Close), FileServiceWriter Close-persists/Abort-discards/idempotency on a real LocalETLFS, remote-run tail-config parity (every FIELDS/LINES field local==decoded, incl. NoEscape; tz name), appendJSONFloat pinned byte-identical to encoding/json across regimes plus NaN/Inf, round-trip guard errors, needsEnclosure boundary cases, %nN entropy cases. - BVT: UPDATE/DELETE/TRUNCATE rejections, terminator-boundary table, enum 'NULL' label round-trip, trailing-CR round-trip, \N rejection (csv custom escape + jsonline), bit whitespace bytes, and DDL error cases for ENCLOSED BY '\', jsonline custom terminator, and %2N. 140/140 + 123/123 against a freshly built 2-CN cluster; key fixes verified live first. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The all-options csv case used FIELDS TERMINATED BY ';'. CI's mo-tester statement splitter breaks on a semicolon inside a quoted SQL string, which desynchronized the script from its expected results (1 direct failure) and skipped the file's cleanup, leaking the wcsv database into later cases' `show databases` output (the temporary_table_limitation and tenant/cache failures in the same run). The local mo-tester handles quoted semicolons, which is why the file passed locally. Use '^' as the separator instead; the case still exercises a custom single-byte separator combined with quote/escape/prefix/terminator options. Results regenerated; both stage BVT files pass locally (123/123 + 140/140). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The INFILE show-create form masked FILEPATH to '' for non-hive external tables and always emitted the optional keys even when empty. For writable external tables that made the emitted DDL semantically lossy: snapshot/PITR restore replays DDL fetched via SHOW CREATE, so a restored table kept its WRITE_FILE_PATTERN but lost its read glob — writable but unreadable — and an embedded 'JSONDATA'='' was accepted at CREATE only to fail at read time. Writable tables (WRITE_FILE_PATTERN present) now emit the real FILEPATH and omit empty optional keys; read-only external tables keep the legacy masked output byte-for-byte. Verified live: SHOW CREATE -> drop -> execute the emitted DDL verbatim -> the recreated table reads the pre-existing stage files and accepts new writes, for both csv and jsonline. The BVT now recreates ext_csv and ext_jl from their emitted DDL and asserts the combined counts (3 -> 6 -> 9); unit tests pin the writable form (FILEPATH present, no empty keys) and the unchanged read-only form. 149/149 + 123/123 locally. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@XuPeng-SH Fixed in 3ab7a82 — you're right that with restore replaying For external tables with a Verified live on the cluster as a full loop: |
XuPeng-SH
left a comment
There was a problem hiding this comment.
I found a few remaining unhappy-path/correctness blockers in the current head.
-
Compressed writable tables can be created, but the write path always emits plain bytes.
validateWriteFilePatternacceptsCOMPRESSION='gzip'and compressed write-path suffixes, whilebuildExternalInsertArg/WriterConfigdo not carry compression andexternalWriterstreams raw CSV/JSONLine. The read path later callscrt.GetUnCompressReader(..., param.Extern.CompressType, param.Fileparam.Filepath, ...), andcrt.GetCompressTypeauto-detects suffixes like.gz/.bz2/.lz4when compression is empty/auto. So a table can be created with a gzip compression setting or a.gzwrite pattern,INSERTcan succeed, and thenSELECTtries to decompress raw CSV/JSONLine and fails. Please either implement compression on the write path or reject writable external tables whose effective compression for produced files is notnone. -
CSV comment handling can corrupt valid writer output. External CSV readers always set
Comment: '#', andcsvparser.readRecordskips any row whoserecordBuffer[0] == '#'after quotes are stripped but before unescape. The writer emits"#lost",1\nfor a first-column string value'#lost', so the same table can silently drop the inserted row on readback. There is also a panic edge case here: if all fields are empty strings, e.g.""\nor"",""\n,recordBufferis empty and the comment check still indexesrecordBuffer[0]. Please either disable comment processing for these external table reads, or make the writer/validator protect/reject the affected values; coverage should include leading#and all-empty string rows. -
Writable DDL accepts CSV delimiter configs that the reader refuses at open time. For example,
FIELDS TERMINATED BY '#'passes the new writable validation, but the actual CSV parser rejects it because the reader's comment marker is also#. Delimiters starting with invalid parser bytes such as newline, carriage return, or quote are the same class of issue. This means the table can be created and written, but any read fails before parsing. Writable DDL should mirror the parser delimiter validation or construct/check the parser config during validation. -
Duplicate external options can make validation check a different configuration than the one later stored/executed.
validateWriteFilePatternusesgetRawOption, which returns the firstformat/jsondata, but the option initializers later walk the whole slice and later duplicates win. A table can therefore validate asformat='csv'and then be stored/executed asformat='parquet', causing runtimeexternal write format "parquet"after CREATE succeeded. Duplicatejsondatacan similarly validate as object but read as array. Please reject duplicate option keys relevant to writable external tables before validation/normalization.
…+ '#' combined
The previous CI run still failed on writable_external_csv_options at the t_all
"everything at once" statement, cascading (via a skipped DROP DATABASE that
leaked the wcsv schema) into temporary_table_limitation and tenant/cache
`show databases` mismatches — the same shape as the earlier quoted-semicolon
failure.
Root cause: t_all's create line was the only one combining an escaped-quote
enclosure ('\'') with later option tokens AND a '#'-bearing terminator
('#EOL#') on one line. Each token is fine alone in CI's mo-tester — t_squote
uses enclosed by '\'' (immediately followed by ';') and passes, and the main
file's `lines terminated by '#'` passes — but the combination on one long
line desynced CI's statement splitter from the .result file.
Swap t_all to a plain '@' enclosure and a 'qEOLq' terminator (no escaped
quote, no '#'); the case still combines a custom separator, enclosure,
escape, STARTING BY and a multi-char line terminator, and single-quote
enclosure stays covered by t_squote. Result regenerated; the data still
round-trips all 11 tricky rows. Passes 123/123 locally.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ng-SH) 1. Compression: validateWriteFilePattern rejects any effective compression — an explicit COMPRESSION option, or a .gz/.bz2/.lz4 suffix auto-detected from the read FILEPATH glob or the WRITE_FILE_PATTERN. The writer streams plain bytes, so a compressed config produced files the read path would try to decompress and fail. (effectiveWriteCompression inlines crt's decision to avoid the plan<-crt import cycle.) 2. CSV '#' comment marker: the external CSV reader hardcoded Comment='#', which silently dropped any row whose first column started with '#' (after quote-stripping, so enclosure could not protect it) and could index recordBuffer[0] out of range on an all-empty-string row. Disable the comment marker for writable-table reads (a value starting with '#' is data; '#' also becomes a usable field terminator), keep it for read-only tables, and guard the parser's comment skip with len(recordBuffer) > 0. 3. Delimiter validation: reject a FIELDS TERMINATED BY whose first byte is a quote, CR, LF or NUL — the CSV reader rejects those at open, so such a table could be created and written but never read. 4. Duplicate option keys: reject duplicate format/jsondata/compression/ filepath/write_file_pattern before validation. getRawOption returns the first, the read-side init keeps the last, so a table could validate as csv and execute/read as parquet. Tests: validateWriteFilePattern cases for compression (option + read/write suffix), duplicate keys, and field-terminator bytes; csvparser tests for the empty-record no-panic and comment-disabled paths; BVT round-trips a '#'-leading value, an all-empty row and a '#' separator, plus DDL error cases for compression, duplicate keys and a quote terminator. 160/160 + 123/123 on the 2-CN cluster; all four fixes verified live. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@XuPeng-SH Thanks — all four were real. Fixed in 71595e6, each verified live on the 2-CN cluster. 1. Compression. 2. CSV 3. Delimiter validation. A 4. Duplicate option keys. Unit tests cover the compression (option + read-suffix + write-suffix), duplicate-key, and terminator-byte cases, plus csvparser tests for the empty-record no-panic and comment-disabled paths. BVT: 160/160 + 123/123 locally. |
2 is the wrong fix. This is a parser bug (old code) and should be fixed in the read path. Vibing it now. |
Supersedes the earlier per-writable-table comment workaround. The CSV parser's comment handling had two problems: cfg.Comment was accepted but never wired into the parser (dead code), and where it would have applied the check was on recordBuffer[0] AFTER unquoting, so an enclosed value like "#x" would have been dropped as a comment. Fix the parser instead of special-casing writable tables: - CSVConfig.Comment is now a string (was byte); the empty string (default) means no comment marker, so every line is data. A configured marker is matched against the line's RAW prefix before unquoting, so an enclosed "#x" is data and only an unquoted line beginning with the marker is skipped. Consecutive comment lines are skipped; the check is gated to a fresh line start so it composes with LINES STARTING BY. - Wire cfg.Comment into the parser so the option actually takes effect. - Both CSV callers (external read, LOAD DATA) now default the marker empty. This matches MySQL LOAD, which does not treat '#' lines as comments, and is behavior-preserving: the marker was never actually applied before. Because the marker no longer shares the tokenizer byte stop-sets, a '#' field terminator no longer collides with it, so the DDL no longer rejects '#' as a writable field terminator. Tests: csvparser cases for the empty-marker (all data), a configured marker matched on the raw prefix (quoted "#x" is data, unquoted '#' line skipped), and the all-empty-row no-panic; the writable field-terminator test allows '#'. BVT round-trips a '#'-leading value and an all-empty row. Verified no regression: load_data 871/871 and the full stage dir 1411/1411 pass, plus the two writable files 160/160 + 123/123. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Correction on the CSV comment-marker fix (#2), now in d8b3806 — my earlier "disable comment for writable reads" was the wrong approach. Fixed the parser properly instead:
Verified no regression from the global default change: |
…option
Builds on the configurable-comment parser change: external tables now accept
a 'comment' option (INFILE{... 'comment'='#'} / 'comment'='REM'), plumbed via
plan.GetCSVComment into the CSV reader's CSVConfig.Comment. Absent/empty (the
default) means no comment marker — every line is data. A configured marker
skips lines whose RAW prefix matches it (so an enclosed "#x" or a mid-line '#'
stays data). The option is added to the allowed external-option keys and the
three Init*Param validators.
BVT load_data/external_csv_comment.sql reads one fixture three ways — default
(6 rows, all data), comment='#' (5 rows; the '#c1,c2' line skipped while
"#quoted" and 3,#midhash remain), comment='REM' (5 rows; 'REMx,REMy' skipped).
Verified live; 19/19. plan/external/csvparser unit suites pass, static-check
clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…alloc)
- Reword four doc/test comments that rendered a curly quote where they meant
to describe SQL quote-doubling / empty ESCAPED BY. gofmt's doc-comment
formatter rewrites a literal '' into a curly quote, so the comments now use
words ("an empty FIELDS ESCAPED BY", "a single quote written as two single
quotes") instead of the bare '' sequence.
- csvparser: the NewCSVParser validation now only checks the field delimiter,
so its error message drops the misleading "or comment delimiter" wording
(and the one test matching the exact string is updated).
- csvparser: the per-line comment-marker check no longer allocates — the
parser holds the marker as []byte (converted once at construction) and
compares with bytes.Equal instead of string(bs).
- Fix the stale "Comment == 0" test comment now that Comment is a string.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The CI failure was 3 cases in table/external_table.sql, all consequences of the directed comment-marker change (default empty, validation checks only the field delimiter): - ex_table_7 uses FIELDS TERMINATED BY '#'. With no comment marker by default, '#' no longer collides with one, so the table now reads its file instead of erroring. Its result is switched to the @Separator:table (boxed) format because col20 contains an internal run of spaces that the plain space- separated .result format cannot represent unambiguously. - ex_table_8 / ex_table_9 (FIELDS TERMINATED BY '\n' / '\r') still error, now with the reworded "invalid field delimiter" message. external_table.sql passes 249/249 locally. (The 'or comment delimiter' string appeared in no other test.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
XuPeng-SH
left a comment
There was a problem hiding this comment.
I rechecked the latest head (66526fa351aeaf4dc149f229ea3ced7b1f15237b). I think a few correctness blockers still need changes before this can land.
-
Writable CSV external tables with a configured
commentmarker can silently drop rows written by this same feature. The reader now passesplan.GetCSVComment(extern)intoCSVConfig(pkg/sql/colexec/external/types.go:291), and the CSV parser skips a record when the raw line prefix matches the comment marker beforeLINES STARTING BYis stripped (pkg/sql/util/csvparser/csv_parser.go:568). However, the writer config rebuilt bybuildExternalInsertArgdoes not carry this marker (pkg/sql/compile/operator.go:989,pkg/sql/colexec/externalwrite/writer.go:50), andvalidateWriteFilePatterndoes not reject it. For example, withcomment='1', inserting a row whose first field serializes as1writes1,...\n, and a later read treats it as a comment. WithLINES STARTING BY 'R>'andcomment='R'/comment='R>', every writer-produced row can be skipped. Please either reject non-emptyCOMMENTfor writable external tables or make the writer guarantee that the raw record prefix cannot match the configured comment marker. -
SHOW CREATEstill drops the external CSVcommentoption.formatInfileExternalOptionsForShowCreateemits filepath/compression/format/jsondata/write_file_pattern for writable infile tables (pkg/sql/plan/build_show_util.go:961), andformatS3ExternalOptionsForShowCreatehas the same gap for S3 options (pkg/sql/plan/build_show_util.go:1000). Since reader semantics now depend oncomment(pkg/sql/plan/utils.go:2054,pkg/sql/colexec/external/types.go:291), replayingSHOW CREATEor restore DDL changes what rows are returned. -
Generated columns are accepted on writable external tables but are not protected or recomputed in the legacy external INSERT/LOAD path. The writable-table validator rejects
auto_incrementbut notGeneratedCol(pkg/sql/plan/build_ddl.go:6038). The external INSERT path uses the minimal legacy builder (pkg/sql/plan/build_insert.go:265) wheregetInsertColsFromStmtincludes generated columns by default (pkg/sql/plan/build_insert.go:375), bypassing the normal binder logic that rejects explicit generated columns and recomputes generated expressions (pkg/sql/plan/bind_insert.go:1108,pkg/sql/plan/bind_insert.go:1435). LOAD into writable external tables similarly uses the legacy project-building path (pkg/sql/plan/build_load.go:521) instead of the modern generated-column filtering inbind_load.go(pkg/sql/plan/bind_load.go:85). This can allow arbitrary values to be stored for generated columns, or store NULL/defaults when the generated column is omitted. Please reject generated columns for writable external tables or route this path through the generated-column rewrite.
Also, the current PR checks show Matrixone Utils CI / Coverage failing.
Local verification note: I tried focused Go test commands for the touched CSV parser/external writer packages, but this checkout fails during build on github.com/unum-cloud/usearch/golang cgo symbols before the tests run, so I could not get a clean local test signal.
… and compression helper - TestNewCSVParserCommentOption: external COMMENT option flows into the CSV reader (default empty => every line data; '#'/'REM' skip matching raw-prefix lines; enclosed value starting with marker stays data) - TestGetCSVComment + extended TestInitInfileParam_Plain: comment and write_file_pattern option parsing - TestEffectiveWriteCompression: compression inference helper Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ject generated cols Addresses three review findings on writable external tables: 1. CSV comment-marker round-trip (writer-side fix, not a DDL rejection): the reader skips a line whose raw prefix matches the COMMENT marker, so a writer row whose first field serialized to that prefix (e.g. comment='1', first field 1) was silently dropped on readback. The writer now encloses the first field of a row ONLY when its unenclosed line prefix would collide with the marker (firstFieldStartsComment), so the line begins with the enclosure byte and reads back as data. String-like columns are already always enclosed; the guard matters for unenclosed types. COMMENT flows into WriterConfig. 2. SHOW CREATE now round-trips the COMMENT option (writable INFILE, read-only INFILE, and S3 branches); reader semantics depend on it, so restore/replay DDL must preserve it. Omitted when unset. 3. Generated columns are now rejected for writable external tables: the minimal external INSERT/LOAD plan neither filters explicit writes nor recomputes the generated expression, so values would be stored arbitrary or NULL/default. Tests: writer comment-guard unit test with a real csvparser round-trip (collision enclosed, non-collision left bare, no-marker regression); validator accepts COMMENT and rejects generated columns; SHOW CREATE comment round-trip for INFILE+S3; BVT ext_bad19 (generated column rejected). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
All fixed and resolved. |
XuPeng-SH
left a comment
There was a problem hiding this comment.
I rechecked the latest head (c604e3919093652626d9d09a83bc2b4fa0675c82). The previous generated-column rejection and COMMENT/SHOW-CREATE plumbing are moving in the right direction, but I still see correctness blockers.
-
COMMENTis still unsafe for writable tables; several accepted configurations make the table skip rows it just wrote. The reader applies the comment marker to the raw line prefix beforeLINES STARTING BYis consumed (pkg/sql/util/csvparser/csv_parser.go:568), andnewCSVParserFromReaderpasses that marker for all formats, including jsonline (pkg/sql/colexec/external/types.go:291). The jsonline writer always starts a record withLineStartingBy+{and has no comment guard (pkg/sql/colexec/externalwrite/encode.go:315), while DDL still allowsCOMMENTon writable jsonline tables (pkg/sql/plan/build_ddl.go:5998). Soformat='jsonline'+COMMENT='{'drops every inserted row on readback.The CSV-side guard is also incomplete. It only runs for non-NULL, currently-unquoted first fields (
pkg/sql/colexec/externalwrite/encode.go:59,pkg/sql/colexec/externalwrite/encode.go:96), andfirstFieldStartsCommentintentionally returns false when the marker is contained in the fixedLINES STARTING BYprefix (pkg/sql/colexec/externalwrite/encode.go:153). Examples that still corrupt data:COMMENT='REM' LINES STARTING BY 'REM:'skips every writer row;COMMENT='"'skips rows whose first column is always quoted (varchar,enum,bit,json, or numeric/date values that need enclosure); andCOMMENT='\N'/COMMENT='\'skips rows whose first column is NULL because the NULL sentinel is written before the guard. Please reject unprotectable COMMENT configurations at DDL time (or reject COMMENT for writable jsonline) and make the CSV writer error/guard for NULL and already-quoted first-field collisions. -
A failed parallel statement can still leave successfully closed sibling files visible.
insert_externalfinalizes each operator's file as soon as that pipeline sees end-of-input and then nils the writer (pkg/sql/colexec/insert/insert.go:265);Reset/Freeonly abort a writer that is still non-nil (pkg/sql/colexec/insert/types.go:127,pkg/sql/colexec/insert/types.go:159), andexternalWriter.Closefinalizes the file (pkg/sql/colexec/externalwrite/writer.go:208,pkg/fileservice/file_service_writer.go:86). In a multi-pipeline INSERT/LOAD, one scope can finish and close a complete output file before a sibling scope later hits an expression/read/write error. The statement returns failure, but the already-closed file remains in the stage; retrying the statement then duplicates that scope's rows. The earlier abort-on-live-writer fix covers partial live streams, but not completed sibling streams in the same failed statement. This needs a statement-level commit/abort strategy, or a way to keep produced files temporary until all parallel writers have succeeded. -
SHOW CREATEstill does not faithfully round-tripLINES TERMINATED BY '\r\n'. The formatter maps both stored"\n"and stored"\r\n"toLINES TERMINATED BY '\\n'(pkg/sql/plan/build_show_util.go:626), while the read/write configs do preserve and honor CRLF (pkg/sql/colexec/external/types.go:277,pkg/sql/compile/operator.go:1023,pkg/sql/colexec/externalwrite/encode.go:333). Replaying SHOW CREATE changes the external-table format from CRLF to LF, so writable tables are still not exact-format recreatable for this supported option.
Also, the latest checks still show Matrixone Utils CI / Coverage failing.
Local verification note: I tried focused tests for csvparser, externalwrite, and plan, but this checkout still fails during build on github.com/unum-cloud/usearch/golang cgo symbols before tests run, so I could not get a local assertion signal.
The CSV reader matches the COMMENT marker against the raw line prefix
before LINES STARTING BY is consumed, and the marker applies to all
formats (jsonline included). Every jsonline record the writer produces
deterministically begins with LINES STARTING BY + '{', and JSON has no
enclosure mechanism to hide it the way the CSV writer's first-field guard
does. So a writable jsonline table with COMMENT='{' would skip every row
it just wrote on readback.
CSV keeps COMMENT support (the firstFieldStartsComment encloser makes it
round-trip); jsonline now rejects any non-empty COMMENT at DDL time.
Unit tests + a BVT error case (ext_bad20) added.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The CSV writer's first-field comment guard only ran for non-NULL,
unenclosed first fields and intentionally gave up when the marker was
contained in the LINES STARTING BY prefix, so several configs still made
the reader skip rows the writer produced (the marker is matched on the
raw line prefix before unquoting / before STARTING BY is consumed):
- COMMENT='REM' + LINES STARTING BY 'REM:' -> every row skipped
- COMMENT='"' (enclosure byte) -> all enclosed first fields
- COMMENT='\' (escape byte) -> escaped first fields
- COMMENT=',' (field terminator) -> empty first field rows
- COMMENT='\N' (NULL sentinel) -> NULL first column rows
These cannot be fixed by the writer's enclose-the-field guard (the
collision IS the enclosure/escape/terminator/sentinel byte), so reject
them at DDL time in a new validateWritableComment:
- COMMENT and LINES STARTING BY are mutually exclusive for CSV
- COMMENT's first byte must not be the enclosure, escape, or field
terminator byte
- COMMENT must not collide with the NULL sentinel \N
The writer guard (firstFieldStartsComment) drops its buggy
len(marker) <= len(startingBy) exemption, now moot since COMMENT and
LINES STARTING BY can no longer coexist.
Verified the reader's LINES STARTING BY handling is correct (the
terminator is preserved across the put-back in readRecord).
Unit tests cover every new rejection plus the still-valid cases; BVT
adds ext_bad21..25 error cases.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The formatter mapped both stored "\n" and "\r\n" to LINES TERMINATED BY '\n', so replaying SHOW CREATE silently downgraded a CRLF writable external table to LF — even though the read/write configs preserve and honor CRLF (external/types.go, operator.go, externalwrite/encode.go). Render \r\n as its own escape sequence, keeping the existing doubled-backslash convention (the result is delivered through a double-quoted SELECT literal that consumes one backslash level). Extract the rendering into a small formatLinesTerminatedBy helper with a unit test pinning \n and \r\n distinct. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code coverage failure is not related to this PR. I hope OpenAI and Claude will converge :). |
What
Make external tables writable. An external table created with a new
write_file_patternoption acceptsINSERT ... SELECTandLOAD, writingCSV or JSONLine files into a
stage://destination. The pattern is astrftime(3)template with two MO extensions:%nN→nrandom decimal digits%U→ a UUIDEach parallel write pipeline produces exactly one file. Reads,
UPDATE/DELETE,and read-only external tables are unchanged.
How
pkg/sql/colexec/externalwrite(new): strftime expander,ExternalWriterwith a fileservice streaming sink (io.Pipe), CSV/JSONLine encoders. Encoders
are const-vector aware and emit only the table's declared columns.
ToExternal/insert_externalalongsideToWriteS3/insert_table. Write config is carried on the GoInsertCtxbuilt at compile time from
TableDef.Createsql. The plan proto is unchanged;pipeline.Insert(proto) gainedto_external+external_stmt_unix_nanoso remote-run rebuilds the operator on the receiving CN.compileInsertroutes external-write nodes to one writer op persource scope (no S3 merge/shuffle) → one file per pipeline, parallel across CNs.
build_insert/build_load);op-aware
checkTableTypeallows writable-external forinsert;initInsertStmtPkey nil-guard (external tables have no PK); the modern DMLbinder defers external targets to the legacy planner.
write_file_pattern(must bestage://, csv/jsonline only,pattern must parse) and accepts it in the read-side option validators.
Semantics / limitations
stage://.failed statement may leave partial files. Documented as a v1 limitation.
UPDATE/DELETEon external tables remain unsupported.Tests
pkg/sql/colexec/externalwrite(expander, encoders),build_ddlvalidator.test/distributed/cases/stage/writable_external_table.{sql,result}—CSV/JSONLine insert + readback, multi-file accumulation, LOAD into external,
and all error cases. Passes 37/37 locally. Existing
stage.sql(external readsDesign + as-built notes:
docs/design/writable_external_table_impl.md.🤖 Generated with Claude Code