hanke580 opened a new issue, #51368:
URL: https://github.com/apache/arrow/issues/51368

   
   ### Describe the bug, including details regarding any error messages, 
version, and platform.
   
   `arrow::csv` silently drops one byte when a CRLF inside a quoted field 
straddles a parser
   block boundary. The `\r` is kept and the `\n` disappears. No error, no 
warning — the value
   simply comes back one byte shorter than it was written.
   
   This reproduces on **Arrow C++ `main`**, in C++, with no Python in the 
picture, and at the
   **shipped default block size** with no options tuned.
   
   **Build under test**
   
   ```
   Arrow C++ main @ 0bd8def  ("GH-51343: [C++][CI][Packaging] Disable 
Precompile Headers
                               on simdjson...", 2026-09-17)
   libarrow.so.2600.0.0, 26.0.0-SNAPSHOT, Release build
   cmake 4.4.3 / g++ 11.4.0 / Ubuntu 22.04 x86_64
   ```
   
   Also reproduces on released **pyarrow 25.0.1** (newest on PyPI), on all 
three entry points —
   `csv.read_csv`, the streaming `csv.open_csv`, and the dataset API — which is 
what places it
   in the shared reader rather than in a wrapper.
   
   **Minimal case.** A CSV whose row 0 holds a quoted value containing one 
embedded CRLF.
   `newlines_in_values = true` is required for such a file; without it the 
reader rejects the
   file outright (`Expected 3 columns, got 2`) rather than corrupting it.
   
   ```
   id,s,p
   0,"zzzz…zzzA<CR><LF>B",x
   1,plain,x
   2,plain,x
   ```
   
   Walking that CRLF one byte at a time across a 1024-byte block boundary, 1028 
offsets tried
   on `main`:
   
   ```
   block_size = 1024, walking one embedded CRLF across the boundary
   
     pad=1012: CR at byte 1023, LF at byte 1024, boundary at 1024
       wrote 1016B ...A\r\n   read 1015B ...zA\r
   
   1 corrupting offset(s), 0 error(s), out of 1028 tried.
   ```
   
   Exactly one offset out of 1028: the one where **the CR is the last byte of a 
block and the
   LF is the first byte of the next**.
   
   **It is not an artefact of a small block size.** With no `ReadOptions` at 
all, so the shipped
   1 MiB block applies, on `main`:
   
   ```
    file bytes  CR at byte  verdict
       1048602     1048575  CORRUPT: LF silently dropped
       1048603     1048576  ok
       1048604     1048577  ok
       1048605     1048578  ok
       1048606     1048579  ok
   ```
   
   **It is specific to the CR–LF pair.** Same sweep on `main`, varying the byte 
sequence placed
   inside the quoted field (1032 offsets each):
   
   | sequence inside the quoted field | corrupting offsets |
   |---|---:|
   | `A\r\nB` (embedded CRLF) | **1** |
   | `A\r\r\nB` (CR CR LF) | **1** |
   | `A\nB` (lone LF) | 0 |
   | `A\rB` (lone CR) | 0 |
   | `A\n\rB` (LF then CR) | 0 |
   | `A€B` (3-byte UTF-8) | 0 |
   
   A lone `\n` or a lone `\r` at exactly the same boundary survives; only the 
two sequences that
   contain a `\r` immediately followed by `\n` lose a byte, and the CR-CR-LF 
case is the same
   defect one byte over. So this is not general boundary mishandling — the 
doubled-quote escape
   and multi-byte UTF-8 paths are sound across the boundary.
   
   ### Reproduction
   
   **C++ (this is the one that runs against `main`).** Build Arrow with 
`-DARROW_CSV=ON`, then:
   
   ```cpp
   #include <arrow/csv/api.h>
   #include <arrow/io/api.h>
   #include <arrow/table.h>
   #include <arrow/array.h>
   #include <fstream>
   #include <cstdio>
   
   int main() {
     const std::string PREFIX = "id,s,p\n0,\"", VAL = "A\r\nB", PATH = 
"/tmp/f.csv";
     const int block = 1024;
     for (int pad = block - 1020; pad < block + 8; pad++) {
       { std::ofstream f(PATH, std::ios::binary);
         f << PREFIX << std::string(pad, 'z') << VAL << 
"\",x\n1,plain,x\n2,plain,x\n"; }
       auto file = arrow::io::ReadableFile::Open(PATH).ValueOrDie();
       auto ro = arrow::csv::ReadOptions::Defaults();  ro.block_size = block;
       auto po = arrow::csv::ParseOptions::Defaults(); po.newlines_in_values = 
true;
       auto co = arrow::csv::ConvertOptions::Defaults();
       co.column_types = {{"id", arrow::int32()}, {"s", arrow::utf8()}, {"p", 
arrow::utf8()}};
       auto table = 
arrow::csv::TableReader::Make(arrow::io::default_io_context(), file,
                                                  ro, po, 
co).ValueOrDie()->Read().ValueOrDie();
       auto col = table->GetColumnByName("s");
       std::string got;
       for (int i = 0; i < col->num_chunks(); i++) {
         auto a = std::static_pointer_cast<arrow::StringArray>(col->chunk(i));
         if (a->length() > 0) { got = a->GetString(0); break; }
       }
       std::string want = std::string(pad, 'z') + VAL;
       if (got != want)
         printf("pad=%d: wrote %zuB, read %zuB -> LF dropped\n", pad, 
want.size(), got.size());
     }
   }
   ```
   
   Prints exactly one line, at `pad=1012`. Drop `ro.block_size = block` and use
   `pad = (1<<20) - PREFIX.size() - 2` to see the same at the shipped default.
   
   **Python (released pyarrow 25.0.1), for convenience:**
   
   ```python
   import pyarrow as pa, pyarrow.csv as pc
   
   PREFIX, VAL = b'id,s,p\n0,"', "A\r\nB"
   
   def check(pad, block):
       with open("/tmp/f.csv", "wb") as fh:
           fh.write(PREFIX + b"z" * pad + VAL.encode() + 
b'",x\n1,plain,x\n2,plain,x\n')
       t = pc.read_csv("/tmp/f.csv",
                       read_options=pc.ReadOptions(block_size=block) if block 
else pc.ReadOptions(),
                       parse_options=pc.ParseOptions(newlines_in_values=True),
                       convert_options=pc.ConvertOptions(
                           column_types={"id": pa.int32(), "s": pa.string(), 
"p": pa.string()}))
       return t.column("s").to_pylist()[0] == "z" * pad + VAL
   
   print(check(1012, 1024))            # False  <- LF dropped
   print(check((1 << 20) - len(PREFIX) - 2, None))   # False, at the shipped 1 
MiB block
   ```
   
   `polars` and `duckdb` read the identical bytes back correctly, so the file 
is well formed and
   this is the reader, not the writer.
   
   ### Where it comes from (a lead, not a confirmed diagnosis)
   
   I have not bisected this to a single line, but the shape of the failure 
matches a specific
   pattern that appears in both the chunker and the parser: a `\r` handler that 
looks ahead one
   byte for the `\n` and gives up if the byte is not in the current buffer.
   
   `cpp/src/arrow/csv/chunker.cc`, in the lexer's unquoted-field path:
   
   ```cpp
   if (ARROW_PREDICT_FALSE(c == '\r')) {
     if (ARROW_PREDICT_TRUE(data != data_end) && *data == '\n') {
       data++;
     }
     goto LineEnd;      // CR at the end of the buffer => treated as a complete 
terminator
   }
   ```
   
   `cpp/src/arrow/csv/parser.cc` has the same look-ahead guard (`data < 
data_end && *data ==
   '\n'`), and additionally treats a `\n` that is the *first* byte it sees as 
an empty line:
   
   ```cpp
   // Special case empty lines: do we start with a newline separator?
   c = *data;
   if (ARROW_PREDICT_FALSE(IsControlChar(c))) {
     ...
     if (c == '\n') {
       data++;
       goto EmptyLine;
     }
   }
   ```
   
   A CR that ends a buffer is therefore accepted as a whole line terminator, 
and the orphaned LF
   that opens the next buffer is then consumed as a separator rather than as 
data. That is
   consistent with every observation above: it needs the pair, it needs the 
split to fall
   between the two bytes, and a lone CR or lone LF at the same offset is 
unaffected.
   
   `FindFirstInternal` in `chunker.cc` runs the lexer over `partial` and then 
over `block`
   (`cpp/src/arrow/csv/chunker.cc`, `LexingBoundaryFinder`), which is the seam 
where the
   carried-over state would need to remember "the previous buffer ended on a 
CR" for the pair to
   be reassembled.


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to