Local files and Parquet¶
Read and write data on the local filesystem: line/JSON text files (
connector='file') and Parquet files or directories (connector='parquet'). Built into the engine core, no external dependency.
Overview¶
These are clink's built-in local-filesystem connectors, registered by clink::core itself rather than an optional impls/ module, so they are always available. Two connector families:
file(aliasfilesystem): newline-delimited text or JSON records. A single TEXT column is read/written as one line per record (thestringchannel); a multi-column table withformat='json'is read/written as one JSON object per line (therowchannel).parquet: Apache Parquet files. Thestring/int64channels read and write single-column files through the sharedArrowBatcher<T>seam; therowchannel (format='json') reads and writes typed-columnar Parquet where each declared column is its own Arrow column. Aparquetsource reads one file (path) or a whole directory (prefix).
For Parquet on object stores or HDFS instead of the local disk, see S3, GCS, Azure and WebHDFS, which ride the same Parquet seams over a remote filesystem.
Dependency and version¶
| Component | Provenance | Version |
|---|---|---|
Engine core (clink::core) |
Built in | n/a |
| Apache Arrow / Parquet | From-source toolchain (the parquet connector only) |
24.0.0 |
No client library and no CLINK_WITH_* knob: the file connector needs nothing beyond the standard library, and the parquet connector is built whenever Arrow is (CLINK_BUILD_ARROW, on by default).
Factories¶
| Factory name | Direction | Channel | Notes |
|---|---|---|---|
file_text_source |
source | string | one line per record |
file_json_source |
source | row | one JSON object per line |
file_text_sink |
sink | string | one line per record |
file_json_sink |
sink | row | one JSON object per line |
file_json_upsert_sink |
sink | row | nets a changelog by primary key |
partition_file_sink |
sink | row | one file per partition_by value (append-only) |
file_2pc_sink_row / file_2pc_sink_string |
sink | row / string | exactly-once (stage then atomic rename) |
parquet_string_source / parquet_int64_source |
source | string / int64 | path (one file, or a directory of parts) or prefix (a directory tree) |
parquet_row_source |
source | row | typed-columnar (one Arrow column per declared column); path may be a directory of parts |
parquet_string_sink / parquet_int64_sink |
sink | string / int64 | a directory of part files, one per subtask per checkpoint interval |
parquet_row_sink |
sink | row | typed-columnar, the same directory of part files |
parquet_row_2pc_sink |
sink | row | exactly-once typed-columnar |
In clink --capabilities these appear under four connector identities: file,
file_2pc, parquet and parquet_2pc (the 2PC records carry the
exactly-once declaration; the plain ones are at-least-once).
Configuration¶
Options are read from BuildContext params in src/cluster/built_in_factories.cpp (the file/parquet source/sink factories) and src/sql/install.cpp (the row-channel factories). On the SQL path they are the WITH (...) properties.
| Option | Applies to | Required | Default | Description |
|---|---|---|---|---|
path |
both | one of path/prefix |
(none) | For file, the file path; a parallel sink appends .<subtask_idx> so each subtask writes a distinct file. For a parquet sink, the directory its part files are written to. For a parquet source, one file, or a directory whose *.parquet parts are all read (what a parquet sink wrote). |
prefix |
parquet source |
one of path/prefix |
(none) | A directory to read recursively; every matching Parquet file is read, sharded round-robin across subtasks (file i is read by subtask i % parallelism). |
recursive |
parquet source (prefix) |
No | true |
Descend into sub-directories when listing. |
suffix |
parquet source (prefix) |
No | .parquet |
Only files whose name ends with this are read. |
format |
file |
No | line text | format='json' selects the multi-column row channel (one JSON object per line). |
partition_by |
file sink |
No | unset | Route rows to one file per distinct value of the named column(s); append-only. |
mode |
file sink |
No | append | mode='upsert' nets a changelog by the table's PRIMARY KEY. |
delivery_guarantee |
file / parquet sink |
No | at-least-once | delivery_guarantee='exactly_once' selects the 2PC sink. |
json_non_finite |
file JSON sink |
No | null |
How a NaN or infinite double is written: 'null', or 'string' for "NaN", "Infinity" and "-Infinity". Any other value is refused. Applies to every format='json' file sink (plain, upsert, exactly-once, partition_by); a parquet sink refuses it. |
The file JSON row source decodes each line against the table's declared columns, as the Kafka JSON bridge does: a DECIMAL value is carried exactly at the column's scale, a REAL value is rounded to float precision, an integer token in a DOUBLE column becomes a double, so x / 2 over {"x":9} is 4.5, and a whole numeral such as 7.0 in a BIGINT or INTEGER column becomes an integer, so n / 2 over {"n":7.0} is 3, as the column's type says. A directory path decodes the same way.
JSON has no token for NaN or an infinity, so the file JSON sinks write a non-finite double as null by default, or as "NaN", "Infinity" and "-Infinity" with json_non_finite='string'. A partition_by sink names the file of a NaN or infinite partition value NaN, Infinity or -Infinity in either case. Read back by the file source, null is SQL NULL, and the strings stay strings: they are not turned back into doubles. See NaN and infinity in JSON output. A parquet sink stores the value itself.
The parquet row source derives its Arrow schema from the table's declared columns (schema_columns), so no separate schema option is needed. It resolves those columns by name, with the same type, in any file: one clink wrote, or one another tool wrote (pyarrow, DuckDB, Spark, ClickHouse). A file from another tool carries none of clink's own columns, and its rows read with no event time; a declared column the file lacks, or holds as another type, is refused at open, naming it.
SQL usage¶
connector='file' (or filesystem) and connector='parquet' are both first-class SQL source and sink connectors. The channel follows the table shape: a single TEXT column uses the string channel, format='json' uses the row channel.
-- Read a directory of Parquet files, aggregate, write JSON lines.
CREATE TABLE events (user_id BIGINT, amount BIGINT) WITH (
connector = 'parquet',
prefix = '/data/events' -- every *.parquet under here, sharded across subtasks
);
CREATE TABLE totals (user_id BIGINT, total BIGINT) WITH (
connector = 'file',
format = 'json',
path = '/out/totals.ndjson'
);
INSERT INTO totals SELECT user_id, SUM(amount) AS total FROM events GROUP BY user_id;
Writing Parquet produces a directory of part files, which a parquet table
reads back whole:
CREATE TABLE pq_events (user_id BIGINT, amount BIGINT) WITH (
connector = 'parquet', path = '/out/events'); -- /out/events/sub0-...-000000.parquet, ...
INSERT INTO pq_events SELECT user_id, amount FROM events;
Exactly-once to local Parquet, partitioned text output, and upsert:
-- exactly-once typed-columnar Parquet (staging/ then atomic rename on checkpoint).
CREATE TABLE pq_out (id BIGINT, px DOUBLE) WITH (
connector = 'parquet', path = '/out/pq', delivery_guarantee = 'exactly_once');
-- one file per 'region' value (append-only).
CREATE TABLE by_region (region STRING, n BIGINT) WITH (
connector = 'file', format = 'json', path = '/out/regions', partition_by = 'region');
Example¶
Programmatic local Parquet round-trip with the single-file ParquetSink<T>,
plus a multi-object directory read. The parquet connector's factories use
ParquetRollingSink<T> (clink/connectors/parquet_rolling_sink.hpp) instead,
so that a job's output survives a restore.
#include "clink/connectors/parquet_sink.hpp"
#include "clink/connectors/parquet_source.hpp"
#include "clink/connectors/multi_object_parquet_source.hpp"
#include "clink/core/arrow_batcher.hpp"
#include <arrow/filesystem/localfs.h>
using namespace clink;
// Write one file.
ParquetSink<std::int64_t> sink("/data/part-0.parquet", int64_arrow_batcher());
sink.open();
Batch<std::int64_t> b;
b.emplace(1);
b.emplace(2);
sink.on_data(b);
sink.close();
// Read a whole directory, sharded for subtask 0 of 4.
MultiObjectParquetSource<std::int64_t>::Options o;
o.prefix = "/data"; // every *.parquet under /data
o.subtask_idx = 0;
o.parallelism = 4;
MultiObjectParquetSource<std::int64_t> src(
[]() -> std::shared_ptr<arrow::fs::FileSystem> {
return std::make_shared<arrow::fs::LocalFileSystem>();
},
o, int64_arrow_batcher());
src.open();
// drive src.produce(emitter) until it returns false; src.close();
Delivery semantics¶
filetext/JSON sink (file_text_sink/file_json_sink): at-least-once. A run that starts from empty state truncates the file; a run restored from a checkpoint appends to it, so the rows before the restore point stay and the rows after it are written again as the sources replay them. Every checkpoint barrier hands the lines before it to the operating system before the checkpoint is acknowledged, which a process failure does not undo (a host failure that loses the page cache is not covered), and a restored run first cuts a partial last line that a kill left behind, so its first line is not glued to the fragment. A write the stream refuses, such as one to a full disk, fails the sink at once. Before these, lines waited in the stream's buffer until it filled or the input ended, so a checkpoint could complete over lines a killed process then lost; a write error went unnoticed; and a restored run appended straight after a torn line. The built-infile_line_sinkandfile_int64_sinkbehave the same way; they used to truncate their file on every open, a restored one included, which erased every line before the restore point.file2PC sink (delivery_guarantee='exactly_once',file_2pc_sink_*): exactly-once. Each checkpoint interval is staged then atomically renamed intocommitted/on the global commit, withwrite_fsync_renamedurability (toggle withCLINK_STATE_FSYNC). This is the local reference implementation the object-store 2PC sinks mirror. Each part is namedsub<N>-<run>-<ckpt>.dat, where<run>is unique to each run of the sink, so a rerun into the same directory adds its files beside the earlier run's and never replaces one. Parts used to be named after the checkpoint alone, and a run that reused an earlier run's checkpoint id renamed its file over that run's: every run that commits only at the end of the input does, since that commit always takes the same id, as can another job writing to the directory.parquetsink (parquet_*_sink,parquet_row_sink): at-least-once, a directory of complete part files (ParquetRollingSink). Each subtask closes a part at every checkpoint barrier and at the end of input, so its output is readable while the job runs and everything up to the last barrier survives a kill. A part is written to<name>.inprogress, fsynced with its directory (unlessCLINK_STATE_FSYNC=0) and renamed when complete, so a reader never sees a torn.parquetfile and the part is on stable storage before the checkpoint that relies on it completes. A run restored from a checkpoint keeps every part already written, including those past the restore point, whose rows the sources then replay; a run that starts from empty state replaces the previous run's parts (only files namedsub<N>-*.parquet). Read the directory with aparquetsource (pathorprefix), which resolves columns by name as for one file,pyarrow.dataset, or DuckDB'sread_parquet('<dir>/*.parquet'). Apaththat still holds the single file an earlier release wrote is refused at open, naming the file.parquet2PC row sink (delivery_guarantee='exactly_once',parquet_row_2pc_sink): exactly-once, the same staging then atomic-rename protocol with complete, externally-readable Parquet files, namedsub<N>-<run>-<ckpt>.parquetso that, as for thefile2PC sink, a rerun into the same directory adds its files beside the earlier run's.- Sources (
file_*_source,parquet_*_source): bounded, and replay from a checkpoint by re-opening and skipping the record/batch count already emitted (deterministic for a fixed file or a fixed directory listing).
Limitations¶
- The
filetext sink, theparquetrolling sink and both 2PC sinks must each be the only sink on their chain: at every checkpoint barrier the first two write out what they received before the barrier and the 2PC sinks prepare the interval, which holds only while it owns its chain's checkpoint, soDag::add_sinkrefuses another sink beside it, whichever is added first. SQL and job graphs plan every sink as a subtask of its own; with the Dag API, put the other sink in a separate Dag (see checkpointing). - The
parquetsource's multi-object directory read (prefix) shards by object across same-parallelism subtasks; cross-parallelism rescale of the assignment is not coordinated. - No Hive-partition pruning or column projection on the Parquet read path; the whole file is read.
- The
filetext sink truncates on open unless the run restores from a checkpoint, and the partitioning sink is append-only. - The programmatic single-file
ParquetSink<T>(see the example) keeps no checkpoint state and writes the file's footer only at close. A process killed before then leaves a file without one, which readers reject; a cancelled task closes the file with the rows it held, so it reads as complete; and a run restored from a checkpoint truncates the file and writes only the rows after the restore point, so those before it are lost. Use it where a failure re-reads the input from the start; theparquetfactory sink keeps its output across a restore. - The typed-columnar
parquet_row_*factories carry one Arrow column per declared column; theparquet_string_source/_sinkfactories carry a single column, so a multi-objectprefixread on the string channel expects single-column files.