| .. Licensed to the Apache Software Foundation (ASF) under one |
| .. or more contributor license agreements. See the NOTICE file |
| .. distributed with this work for additional information |
| .. regarding copyright ownership. The ASF licenses this file |
| .. to you under the Apache License, Version 2.0 (the |
| .. "License"); you may not use this file except in compliance |
| .. with the License. You may obtain a copy of the License at |
| |
| .. http://www.apache.org/licenses/LICENSE-2.0 |
| |
| .. Unless required by applicable law or agreed to in writing, |
| .. software distributed under the License is distributed on an |
| .. "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| .. KIND, either express or implied. See the License for the |
| .. specific language governing permissions and limitations |
| .. under the License. |
| |
| .. Ported from the Paimon documentation: |
| .. https://github.com/apache/paimon/blob/master/docs/docs/concepts/rest/tables.mdx |
| |
| .. default-domain:: cpp |
| .. highlight:: cpp |
| |
| Format Table |
| ============ |
| A format table is a directory that holds multiple files of the same format. It carries no |
| snapshots and no manifests: the files in the directory are the table, so reading it lists |
| directories and writing to it adds files. A table is a format table when its ``type`` option is |
| ``format-table``; ``file.format`` then names the format of every file in it, which here is |
| ``parquet`` or ``orc``. |
| |
| A partitioned format table uses the standard Hive directory layout, and its partitions are |
| discovered from that layout rather than from metadata. By default a partition directory is named |
| ``key=value``; setting ``format-table.partition-path-only-value`` names it by the value alone. |
| |
| Because a directory of plain files records no row identity, a format table only accepts inserts. |
| Reads still carry the leading ``_VALUE_KIND`` field every ``BatchReader`` promises, so an engine |
| that reads a batch by field index sees the same layout it does for a managed table; every row of a |
| format table is an insert. |
| |
| Reading and writing |
| ------------------- |
| A format table is not served through :cpp:func:`Catalog::GetTable`, which describes a managed |
| table; use :cpp:func:`Catalog::GetFormatTable` instead. |
| |
| Reading and writing go through the entry points every other table uses: ``TableScan::Create``, |
| ``TableRead::Create``, ``FileStoreWrite::Create`` and ``FileStoreCommit::Create``, each from its |
| usual context builder. Each reads the table's schema - from under the table path, or from the one |
| the context carries - and dispatches to a format table when its ``type`` says so. |
| |
| That path needs a schema under the table's own location, which is what every table a file system |
| catalog serves has. A table loaded from a catalog that keeps the schema elsewhere, such as the |
| REST catalog, has none: nothing under the location says that it is a format table, nor that |
| ``schema`` and ``branch`` below it are data rather than metadata. Such a table is read and written |
| by handing the loaded ``FormatTable`` to the context instead of a path - every context builder has |
| a constructor taking one - which is what Java does through ``FormatTable.newReadBuilder()`` and |
| ``newBatchWriteBuilder()``: |
| |
| .. code-block:: cpp |
| |
| PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<paimon::FormatTable> table, |
| catalog->GetFormatTable(paimon::Identifier("db", "tbl"))); |
| paimon::ScanContextBuilder scan_builder(table); |
| PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<paimon::ScanContext> scan_context, |
| scan_builder.Finish()); |
| PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<paimon::TableScan> scan, |
| paimon::TableScan::Create(std::move(scan_context))); |
| |
| The table already carries its schema and the file system it was loaded through, so a context built |
| from one refuses ``SetTableSchema()``, ``WithFileSystem()``, |
| ``WithFileSystemSchemeToIdentifierMap()`` and a branch rather than quietly ignoring them. Options |
| given at the call still win over the ones the schema stored, as they do everywhere else. |
| |
| A batch comes back in the table's column order, or the projection's when the read names one, |
| behind the ``_VALUE_KIND`` field every ``BatchReader`` carries. Which columns a *file* is asked for |
| and in what order is its own business: they are matched to the table's by name, as Java matches |
| them through ``ParquetReaderFactory`` and orc's schema evolution, so a file that stores them in |
| another order is read in the order it stores them, and one holding columns the table does not name |
| is read past. A column the file does not hold is filled with nulls, which is how a file written |
| before a column was added is still read - unless the table declares that column ``NOT NULL``, when |
| the file is refused instead, as Java's ``VectorizedParquetRecordReader`` refuses it. Only the |
| columns a read actually asks a file for are held to the table's schema: a partition column is |
| rebuilt from the directory name whether or not the file stores one of its own, and a column |
| outside the projection is never read, which is how Java scopes the same checks. |
| |
| ``FormatTable`` is the only format-table type in the public API. The classes behind it - |
| ``FormatTableScan``, ``FormatTableRead``, ``FormatTableWrite``, ``FormatTableCommit``, |
| ``FormatDataSplit`` and ``FormatCommitMessage`` - are implementation details under ``src`` and are |
| named here only to describe what happens. A caller never needs them: a plan comes back as |
| ``Plan``, a split as ``Split``, and a commit message as ``CommitMessage``. |
| |
| A catalog that can load a format table itself overrides ``Catalog::LoadFormatTable()``, the |
| protected hook :cpp:func:`Catalog::GetFormatTable` calls. The file system catalog uses it to say |
| its metadata lives under the table location, and the REST catalog to take the location and the |
| schema from one response instead of two that could disagree. A catalog that does not override it |
| still serves format tables, by reading the location and the schema through the virtuals every |
| catalog has. |
| |
| ``TableScan::ListPartitions()`` lists the partitions a scan can see. A format table answers it by |
| listing directories; every other table type returns ``NotImplemented`` for now. |
| |
| Not all of the generic interfaces fit. ``FileStoreCommit`` is mostly about snapshots and manifests - |
| expiring them, rolling back to one, filtering by a commit identifier recorded in one - and a format |
| table keeps none of that state, so those calls are refused rather than quietly doing nothing. |
| ``FileStoreWrite::Compact()`` is refused for the same reason, and both write and commit take batch |
| writes only, since there is no snapshot to record a commit identifier or a watermark in. |
| ``TableScan`` takes a partition filter and a limit; a predicate or a bucket filter is refused. Java |
| refuses a predicate from ``FormatTableScan.withFilter`` too, but ``FormatReadBuilder.newScan()`` |
| splits one first and hands the partition half to the scan, so there a predicate over partition |
| columns still prunes directories. See the limits below. |
| |
| Options given at the call win over the ones the schema stored, as they do for every other table - |
| except ``type``, which is structural and is read from the schema alone, so one read or write |
| cannot decide what kind of table this is. The merged result is validated, not the schema's own |
| options, so an option a format table refuses - ``metastore.partitioned-table``, a file format |
| nothing here can read - is refused wherever it comes from rather than dropped in silence. |
| |
| A setting the format path cannot act on is refused by name rather than quietly dropped: |
| |
| * ``ReadContextBuilder::SetReadSchema()``. A projected read schema can rename a column, prune a |
| nested one and give it metadata of its own; a format table's projection is a list of top-level |
| names, so name the columns instead. |
| * ``WithStreamingMode()`` on a scan or a write, a global index result on a scan, and a real-time |
| context on a scan, a read or a write: a format table has no snapshots, no real-time store and no |
| index. |
| * ``WriteContextBuilder::WithWriteSchema()``, which names a subset of the columns to write. |
| * ``WriteContextBuilder::WithWriteId()``, which prefixes a postpone-bucket writer's files so one |
| compaction reader can put them back in order; a format table has no buckets. |
| * ``CommitContextBuilder::IgnoreEmptyCommit(false)``, ``UseRESTCatalogCommit(true)`` and |
| ``AppendCommitCheckConflict(true)``. Keeping an empty commit means writing a snapshot that adds |
| no files, a rest-catalog commit sends that snapshot to a catalog, and the conflict check reads |
| the manifests of concurrent commits - none of which exist here. Each is refused only when set |
| away from its default, so an ordinary commit is unaffected. |
| * the ``branch`` option, whatever names it: a format table keeps no metadata to branch, and its |
| data is the files under its one location, which is where a read or a write would go whichever |
| branch was asked for. It is refused wherever it comes from - the schema, a catalog that parsed |
| ``tbl$branch_dev``, or the options of a single call - and so is ``WithBranch()`` on a context |
| built from a format table. A branch quietly ignored would be the wrong answer everywhere and a |
| destructive one for an overwrite, which would clear the data it was told not to touch. The |
| value naming the main branch is the default and is left alone. |
| * A scan predicate or bucket filter, as above, and more than one partition filter: a scan descends |
| one directory layout, so it takes the values of a single partition rather than a set of them. |
| |
| What a data file is opened with is not one of the refusals. ``EnablePrefetch()``, the read-ahead |
| cache and its ``CacheConfig``, and the ``Cache`` a read carries all apply, because a format table |
| opens its files through the same component the managed table path opens its own with. What differs |
| between the two paths is which files there are and how a row is put back together, not how a file |
| is read. |
| |
| Some settings are not refused because they describe machinery the format path never reaches, and |
| refusing them would refuse the defaults: ``EnableMultiThreadRowToBatch()`` on a read, a write's |
| temporary directory and spill configuration, and ``WithIgnoreNumBucketCheck()`` and |
| ``WithIgnorePreviousFiles()`` on a write. They have no effect here: a format read hands out the |
| batches parquet or orc already produced rather than assembling them from rows, a format write |
| buffers in memory and never spills, and a table with no buckets has no bucket count to check and |
| no previous files to read back. |
| |
| A write is two-phase, since a directory has no metadata to switch atomically: a file is written |
| into a ``_temporary`` directory beside where it will end up, under a hidden name of its own, and |
| only the commit renames it into place. That is the layout Java Paimon's |
| ``RenamingTwoPhaseOutputStream`` stages under. The directory and the name are both hidden, the |
| convention a Hive-style directory uses for output that is not committed table data, and what a scan |
| of this table skips. The ``_temporary`` directory is shared with every other writer |
| of the same table and is left behind after a commit. |
| |
| An overwriting commit clears what it replaces before publishing anything. What it replaces is the |
| static partition when one is given - the partitions below that prefix, whether or not this commit |
| wrote to them - and otherwise the partitions the commit actually writes to, when |
| ``dynamic-partition-overwrite`` is on, its default, and the table is partitioned. With the option |
| off, or for an unpartitioned table, it replaces the whole table instead, which is the condition |
| Java's ``FormatTableCommit`` and a managed table commit both apply. An overwrite that replaces the |
| table empties it even when it publishes no file, so a statement whose query returns nothing still |
| clears what was there. |
| |
| A plan is in-memory only. ``FormatDataSplit`` has no serialized form - ``Split::Serialize()`` |
| refuses it - and neither has ``FormatCommitMessage``: a format table's plan has no cross-runtime |
| encoding, so plan, read and commit within one process. |
| |
| A ``FormatTableWrite`` and a ``FormatTableCommit`` are each driven by one thread, but separate |
| ones may fill and add to a table at once: each write stages its files under a uuid of its own, and |
| each commit publishes only the files its own messages name. Two *overwriting* commits over the |
| same directory race, since an overwrite clears what is committed there before publishing anything. |
| A ``FormatTableScan`` may be shared, since planning leaves it as it was. |
| |
| ``TableRead::CreateCountReader()`` is not implemented for a format table, so counting its rows |
| means reading them. That is a gap here rather than something the layout forces: ``parquet`` and |
| ``orc`` both record a row count in their own footer. |
| |
| A writer starts a new file once the one it is filling reaches ``target-file-row-num`` rows or |
| ``target-file-size`` bytes. Both are checked between batches rather than between rows, because a |
| batch is the unit this API writes in, so a file may pass either target by up to one batch. Java |
| checks the row count on every row and the size every thousand rows, and its files therefore sit |
| closer to the target. |
| |
| Aborting a write |
| ---------------- |
| ``FormatTableWrite::Abort()`` removes the files the write staged. It is the one call still allowed |
| after ``PrepareCommit()``, and that is what it is for: a write dropped *before* preparing clears |
| its staged files from its own destructor, so only a commit that is prepared and then abandoned |
| needs it. |
| |
| Path containment is checked on the path text, which stops a ``..`` from leaving the table but not |
| a symbolic link pointing out of it - the same as Java's own local file system behaviour. A |
| relative local location is resolved against the working directory first, as the local file system |
| resolves one while opening a file, so a table located at ``./table`` and the absolute paths its |
| own listing hands back are recognised as the same place. ``FormatTable::Location()`` still reports |
| the location as it was given. |
| |
| ``FormatTableCommit::Abort()`` does the same for the messages a commit was given. **Neither undoes |
| a commit that succeeded**: once a file has been renamed into place it is no longer staged, and |
| nothing here will take it back. Java's committer removes the published path as well as the staged |
| one, which matters there because a commit publishes file by file with nothing watching; here a |
| commit that fails part way takes back every target it tried to publish before it returns, so an |
| abort is left with the staged files alone. That includes the target of the rename the commit |
| failed on, which a store may have produced before failing to say so - on an object store a rename |
| is a copy followed by a delete, and a request that took effect but lost its response looks like |
| one that did nothing. The target is removed on the strength of it belonging to this write: its |
| name carries the uuid of the write that staged it, and no two messages of one commit may name the |
| same target. An overwriting commit is the exception: it has already deleted what it replaces, so |
| taking the published files back too would leave neither the old rows nor the new, and they are |
| kept. Java preserves them for the same reason. |
| |
| Both are best effort and never fail here, so a warning in the log is the only signal that a file |
| could not be removed - and a published target left behind is one a scan goes on to read. Java's |
| ``abort`` instead collects its failures and reports them. |
| |
| Give ``FormatTableCommit`` only the messages this job's own writers produced. A message names a |
| staged file by path, and a commit can tell that the path belongs to this table, sits in the |
| partition the message declares, and is staged rather than already published - not whose staged file |
| it is. A well-formed message from somewhere else is published, or discarded by ``Abort()``, like |
| any other. |
| |
| Relationship to Java Paimon |
| --------------------------- |
| Java serves format tables from a Hive or REST catalog, which holds the schema. This implementation |
| also serves them from a file system catalog, which keeps the schema under the table directory - an |
| extension Java does not have. Only for such a table are the ``schema`` and ``branch`` directories |
| below the location treated as metadata rather than as data. |
| |
| A file system catalog keeps a table's schema in ``schema`` and its branches in ``branch`` below |
| the table location, so under ``format-table.partition-path-only-value`` the first partition value |
| may not be ``schema`` or ``branch``: the directory a write would use is the one holding the |
| table's own metadata. Such a write is refused, as is an overwrite naming that partition - which |
| would otherwise delete the schema. A table served from a REST or Hive catalog keeps its schema |
| elsewhere, so there these are ordinary partition values and are read and written like any other. |
| |
| Under that same layout a partition value may not start with ``_`` or ``.`` either, whichever |
| catalog serves the table: the value is the whole directory name, and a scan skips every hidden |
| name. Java writes such a directory and then cannot read it back; here the write is refused |
| instead. The one exception is the value standing for a null partition, ``partition.default-name``, |
| which the scan reads at a partition level by design. Under the ``key=value`` layout the question |
| does not arise, since the key in front of the value keeps the directory name visible. |
| |
| A few smaller differences come from this library's own conventions: |
| |
| * a write takes one partition per batch: the batch declares it through |
| ``RecordBatchBuilder::SetPartition()`` and every row of that batch is stored under it. Java |
| routes row by row, so one write call there may land in any number of partitions; |
| * a write takes its partition from ``RecordBatchBuilder::SetPartition()`` rather than from the |
| rows. A row's own partition column is not checked against the declaration and never reads back: |
| a format table's partition columns are rebuilt from the directory name, as a managed table's are |
| from the manifest, and the managed write path takes ``RecordBatch::GetPartition()`` on the same |
| terms. The values arrive as text. They are still read into their column types and rendered back |
| out before anything is named after them - the round trip Java's writer makes when it renders a |
| partition out of the row it is writing, through the partition computer its |
| ``FileStorePathFactory`` holds. The table therefore decides the directory name and the commit |
| message, not the spelling the caller used: with ``partition.legacy-name`` on, its default, a |
| ``DATE`` partition is written as its day count whether the caller wrote ``19723`` or |
| ``2024-01-01``, and as ``YYYY-MM-DD`` when the option is off. A value that cannot be read into |
| its column type is refused. ``FormatTableCommit``'s static partition is *not* put through that |
| round trip and is used as given, which is what Java's ``FormatTableCommit.buildPartitionPath`` |
| does with it too; |
| * a commit message carries the partition its file belongs to, and a commit checks that it agrees |
| with the directory the file sits in. Java's message carries none and derives the partition from |
| the committer's target path, so the two cannot disagree there. A message here reaches the commit |
| through the base ``CommitMessage`` type, so the value is checked rather than trusted; |
| * a projection that names the same column twice is rejected when the read is built. Java reads |
| such a column once per entry; |
| * a row limit is the caller's. ``FormatTableScan`` takes one, but a format table records no row |
| counts, so only a non-positive limit prunes anything - it plans nothing at all. A positive one |
| neither drops splits nor bounds the reader ``FormatTableRead`` hands out, so the caller stops |
| calling ``NextBatch()`` once it has enough. Java wraps its reader in a ``LimitRecordReader``. |
| |
| Current limits |
| -------------- |
| The layout, the option precedence and the read, write and overwrite semantics described above |
| follow Java Paimon's. What this implementation covers is nonetheless a subset of what Java's |
| format table does; it does not yet support: |
| |
| * the ``csv``, ``json``, ``text`` and ``mosaic`` file formats, leaving ``parquet`` and ``orc``. |
| The first three are line-delimited text in Java, which shares one line-reading layer between |
| them; this library has no text file format at all, so the first of them to be added has to bring |
| that layer with it. ``mosaic`` is none of those: it has a reader and a writer of its own, which |
| this library builds under ``PAIMON_ENABLE_MOSAIC``, but a format table does not reach them yet; |
| * cutting one large data file into byte ranges so that several readers share it. Java does this |
| for an uncompressed ``csv`` or ``json`` file written with the default line delimiter, and for no |
| other format - not for ``text`` or ``mosaic`` either; ``parquet`` and ``orc`` each record where |
| their own row groups and stripes begin, and a reader handed a byte range of one would have to |
| find that out for itself; |
| * ``metastore.partitioned-table``, which moves partition visibility into the catalog, and the |
| Hive partition sync that goes with it; |
| * partition filters beyond equality on partition values, where Java accepts a full predicate. The |
| values are compared through their column type, as Java's are, but discovery here lists one |
| directory level at a time and tests each name's value, while Java turns a leading run of |
| equality constraints into a path and starts listing below it; a table with many partitions |
| therefore costs more listings here than in Java; |
| * ``scan.ignore-corrupt-files`` and ``scan.ignore-lost-files``, which are not implemented: a |
| corrupt or missing data file fails the read rather than being skipped; |
| * reading a column whose type in the file is not the type the table declares. A column is matched |
| by name and then read as the table declares it, so a file storing it as some other type fails |
| the read rather than being converted to that type. Java's orc reader converts a few such pairs, |
| through orc's own schema evolution. The same goes for a ``STRUCT`` whose children the file |
| orders differently, which the read refuses rather than putting back in the table's order. A |
| ``TIMESTAMP`` of another unit is the one difference the read carries, and how far depends on |
| the format: an orc file records no unit at all - a value is a seconds-plus-nanoseconds pair, |
| built at whatever precision the read asks for - so any unit is read as the table declares it, |
| while a parquet file records the unit its values are in and only ``MILLI`` under a ``SECOND`` |
| column is rescaled, the pair parquet's own round trip needs, every other being refused rather |
| than relabelled; |
| * ``FileStoreCommit::TruncateTable()``, which Java implements for a format table by deleting its |
| data files while leaving the partition directories in place, and |
| ``FileStoreCommit::DropPartition()``, which would remove a partition directory outright. An |
| overwrite of a partition with no messages empties it but leaves the directory, which is what |
| Java's ``truncatePartitions()`` does rather than a drop; |
| * ``format-table.commit-hive-sync-url``, which registers committed partitions with a Hive |
| metastore; |
| * column default values. Java replaces a null in a column whose schema field declares a default |
| with that default as it writes; here the null is written as it came; |
| * a table every one of whose columns is a partition column. Java projects the partition columns |
| out of what it writes, leaving files that carry nothing but a row count; here such a schema is |
| refused when the table is created and when it is opened; |
| * ``TIMESTAMP``, ``DECIMAL``, ``FLOAT`` and ``DOUBLE`` partition columns, which Java allows. This |
| is a restriction of the whole library rather than of format tables. The types that do work are |
| ``BOOLEAN``, ``TINYINT``, ``SMALLINT``, ``INT``, ``BIGINT``, ``STRING`` and ``DATE`` - the set |
| the managed table path reads and writes partitions in. Any other, ``BINARY`` among them, is |
| refused when the table is created and when it is opened, rather than at the first read or write: |
| validation asks by building the partition computer that does the round trip, so there is one |
| answer rather than a list of types that could fall out of step with it. |
| |
| ``data-file.path-directory`` has no effect here, and none in Java either: Java's format table |
| writer builds its paths from the table root rather than from that directory. |
| ``format-table.implementation`` is honoured by the engines rather than by the table - in Java |
| Spark it selects between Paimon's own implementation and the engine's ``FileTable`` - so it has |
| no meaning inside this library. |
| |
| Validation |
| ---------- |
| A table Java can serve and this library cannot is refused at creation rather than accepted and |
| then found unopenable, whichever catalog it is created through. It can still reach a catalog |
| another way - written by Java, or by an older client - so the same checks run again when the |
| table is opened. |