hanke580 opened a new issue, #4145:
URL: https://github.com/apache/iceberg-python/issues/4145
### Apache Iceberg version
main (development)
### Please describe the bug 🐞
With a local warehouse (`warehouse="file://..."`, `PyArrowFileIO` with
`PyArrowLocalFileSystem`), PyIceberg writes
every new file (Parquet data files, `*-m0.avro` manifests, `snap-*.avro`
manifest lists, `NNNNN-<uuid>.metadata.json`)
through `pyarrow.fs.LocalFileSystem.open_output_stream()` and closes it, but
never calls `fsync` on any of them or on
their directories. It then swaps the catalog pointer to the new
`metadata.json`. The catalog backend syncs its own
commit (SQLite: `fdatasync` of the journal, the directory and the database
file), and that commit also forces the
ext4 journal, which makes the *creation* of the new files durable while
their *contents* are still only in the page
cache.
After a power loss (or kernel crash) shortly after an acknowledged commit,
every file PyIceberg wrote is present
but 0 bytes, and the catalog points to a 0-byte `metadata.json`:
```
pyiceberg.catalog.load_table("ns.t")
ValidationError: 1 validation error for TableMetadataWrapper
Invalid JSON: EOF while parsing a value at line 1 column 0
[type=json_invalid, input_value='', input_type=str]
```
So one commit makes the whole table unloadable, including every earlier
snapshot, because the table is opened
only through the newest metadata file and the older metadata files are just
as empty. There is no supported way
to fall back. A plain process kill (no power loss) is always fine, because
the page cache survives.
With the SQLite catalog there is a second, smaller effect: SQLite's default
rollback journal (`journal_mode=DELETE`,
`synchronous=FULL`) commits by unlinking `catalog.db-journal` without
syncing the directory, so after the power
loss the hot journal rolls back the most recent catalog transaction. The
catalog then points to the previous
`metadata.json` (also 0 bytes), and a table created just before the cut
disappears from the catalog. That part
would be fixed by `PRAGMA synchronous=EXTRA` (or WAL mode) on the SQLite
connection. The main problem, the
unsynced warehouse files, is independent of the catalog: with a PostgreSQL
catalog (on another disk) and a local warehouse
the new pointer survives the cut and points to a `metadata.json` that is
either missing (cut before ext4's next
journal commit: `FileNotFoundError`) or 0 bytes (cut after it: the same
`ValidationError`).
#### How to reproduce
Linux, ext4. `pip install "pyiceberg[sql-sqlite,pyarrow] @
git+https://github.com/apache/iceberg-python@main"` (or
`pyiceberg[sql-sqlite,pyarrow]==0.12.0`); pyarrow 25.0.1 / 19.0.1,
SQLAlchemy 2.0.55.
`ice_write.py` (create a table and append `n` batches):
```python
import sys
import pyarrow as pa
from pyiceberg.catalog.sql import SqlCatalog
d, n = sys.argv[1], int(sys.argv[2])
cat = SqlCatalog("default", uri=f"sqlite:///{d}/catalog.db",
warehouse=f"file://{d}/wh")
cat.create_namespace("ns")
schema = pa.schema([("id", pa.int64()), ("v", pa.string())])
t = cat.create_table("ns.t", schema=schema)
print("ACK create_table ns.t", flush=True)
for i in range(n):
lo = i * 100
t.append(pa.table({"id": list(range(lo, lo + 100)), "v": [f"r{j}" for j
in range(lo, lo + 100)]}, schema=schema))
print(f"ACK append {lo}..{lo + 100}", flush=True)
```
**1. The missing syncs, no power cut needed.** Trace the writer (one append):
```
mkdir -p /tmp/w
strace -f -y -qq -e trace=openat,fsync,fdatasync,rename,unlink -o trace.txt
python ice_write.py /tmp/w 1
```
Relevant lines (paths shortened to `D`):
```
openat(... "D/wh/ns/t/metadata/00000-cf4c...metadata.json",
O_WRONLY|O_CREAT|O_TRUNC, 0666) = 4
fdatasync(4<D/catalog.db-journal>) = 0 <- SQLite commits create_table
fdatasync(5<D>) = 0
fdatasync(4<D/catalog.db-journal>) = 0
fdatasync(3<D/catalog.db>) = 0
unlink("D/catalog.db-journal") = 0
openat(... "D/wh/ns/t/data/00000-0-5714...parquet",
O_WRONLY|O_CREAT|O_TRUNC, 0666) = 4
openat(... "D/wh/ns/t/metadata/5714...-m0.avro", O_WRONLY|O_CREAT|O_TRUNC,
0666) = 4
openat(... "D/wh/ns/t/metadata/snap-7160...avro", O_WRONLY|O_CREAT|O_TRUNC,
0666) = 4
openat(... "D/wh/ns/t/metadata/00001-1f6f...metadata.json",
O_WRONLY|O_CREAT|O_TRUNC, 0666) = 4
fdatasync(4<D/catalog.db-journal>) = 0 <- SQLite commits the new
metadata_location
fdatasync(5<D>) = 0
fdatasync(4<D/catalog.db-journal>) = 0
fdatasync(3<D/catalog.db>) = 0
unlink("D/catalog.db-journal") = 0
```
No `fsync`/`fdatasync` of any file under `wh/` and of no directory under
`wh/`, before or after the catalog commit.
**2. Emulated power cut.** The warehouse is on ext4 over a device-mapper
linear target; after the writer has exited,
the table is switched to `error` without flushing, which drops everything
not yet on the device, like a power cut
(`commit=3600` only makes the run deterministic by disabling ext4's own 5 s
journal commit; with the default
`commit=5` we got the same result for cuts up to about 10 s after the writer
exited, after which background writeback
had usually written the files). As root:
```bash
W=$(mktemp -d); IMG=$W/disk.img; MP=$W/mnt; mkdir -p $MP
truncate -s 1G $IMG; LOOP=$(losetup -f --show $IMG)
dmsetup create icecut --table "0 2097152 linear $LOOP 0"
mkfs.ext4 -q /dev/mapper/icecut; mount -o noatime,commit=3600
/dev/mapper/icecut $MP; sync -f $MP
python ice_write.py $MP 2 # ACK create_table, ACK append
0..100, ACK append 100..200
sleep 2
# power cut
dmsetup suspend --noflush --nolockfs icecut
dmsetup load icecut --table "0 2097152 error"; dmsetup resume icecut
umount -l $MP; dmsetup remove icecut; losetup -d $LOOP
# "reboot"
LOOP=$(losetup -f --show $IMG); mount $LOOP $MP
find $MP/wh -type f -printf '%s %p\n'
python -c "
from pyiceberg.catalog.sql import SqlCatalog
cat = SqlCatalog('default', uri='sqlite:///$MP/catalog.db',
warehouse='file://$MP/wh')
print(cat.load_table('ns.t').scan().to_arrow().num_rows)"
umount $MP; losetup -d $LOOP
```
Output on main (before the cut the same `load_table` returns 200 rows):
```
0 .../wh/ns/t/data/00000-0-74ed3971-...parquet
0 .../wh/ns/t/data/00000-0-d879fe98-...parquet
0 .../wh/ns/t/metadata/00000-c4457e76-...metadata.json
0 .../wh/ns/t/metadata/00001-30081b89-...metadata.json
0 .../wh/ns/t/metadata/00002-03b3aab8-...metadata.json
0 .../wh/ns/t/metadata/74ed3971-...-m0.avro
0 .../wh/ns/t/metadata/d879fe98-...-m0.avro
0 .../wh/ns/t/metadata/snap-4421089994007795917-0-d879fe98-...avro
0 .../wh/ns/t/metadata/snap-9103608114772306948-0-74ed3971-...avro
pydantic_core._pydantic_core.ValidationError: 1 validation error for
TableMetadataWrapper
Invalid JSON: EOF while parsing a value at line 1 column 0
[type=json_invalid, input_value='', input_type=str]
```
(The catalog row now points to `00001-...metadata.json`, previous
`00000-...`: the last SQLite transaction was
rolled back from the hot journal, see above. Both files are empty.) With `n
= 0` (only `create_table`), the table
is gone: `NoSuchTableError: Table does not exist: ns.t`.
We ran the same with a longer workload (appends, `delete`, `overwrite`, a
schema change, a second table created and
dropped; 1 to 10 commits, plain cut and cut right after an ext4 journal
commit): every run after the first data
commit ends with the `ValidationError` above, on main and on 0.12.0. Plain
`SIGKILL` at the same points: the table
loads with all acknowledged rows.
#### Expected behavior
Once `append()` / `create_table()` / any commit returns, the files the new
metadata references and the
`metadata.json` itself are durable before the catalog pointer is swapped, so
that a power loss leaves the table at
the last acknowledged commit (or, at worst, at an earlier one), never
pointing at a file that is empty.
On object stores an acknowledged PUT is durable, so the commit protocol is
safe there. On a local file system the
FileIO has to provide the same guarantee: `fsync` each written file before
closing it (data files, manifests,
manifest lists, metadata JSON) and `fsync` the directories it created
entries in, before the catalog commit.
#### Where in the code (main @ eb30d8ae)
- `PyArrowLocalFileSystem.open_output_stream` only creates the parent
directory and delegates to
`pyarrow.fs.LocalFileSystem`, whose output stream does not sync on close:
https://github.com/apache/iceberg-python/blob/eb30d8ae6ac4054f751b91cb04d4ae5026354cc2/pyiceberg/io/pyarrow.py#L252-L256
- `PyArrowFile.create` returns that stream for every file PyIceberg writes:
https://github.com/apache/iceberg-python/blob/eb30d8ae6ac4054f751b91cb04d4ae5026354cc2/pyiceberg/io/pyarrow.py#L357-L385
- `Catalog._write_metadata` writes the new metadata JSON through it:
https://github.com/apache/iceberg-python/blob/eb30d8ae6ac4054f751b91cb04d4ae5026354cc2/pyiceberg/catalog/__init__.py#L1126-L1127
- `SqlCatalog.commit_table` writes the metadata file and then commits the
new `metadata_location`:
https://github.com/apache/iceberg-python/blob/eb30d8ae6ac4054f751b91cb04d4ae5026354cc2/pyiceberg/catalog/sql.py#L479-L560
(`create_table` likewise, L245-L296).
- `file` maps to `PyArrowFileIO`, falling back to `FsspecFileIO`, whose
local file system does not sync either:
https://github.com/apache/iceberg-python/blob/eb30d8ae6ac4054f751b91cb04d4ae5026354cc2/pyiceberg/io/__init__.py#L325
The documentation describes the local SQLite + local file system
getting-started setup as not for production "due to
the limited scalability", and says "SQLite is not built for concurrency, you
should use this catalog for exploratory
or development purposes"; neither it nor the FileIO docs say that a local
(`file://`) warehouse, with any catalog, can
be left unloadable by a power loss after an acknowledged commit.
For comparison: the Java `HadoopFileIO` on `file://` (Hadoop
`RawLocalFileSystem`) does not sync on close either,
while iceberg-rust's local file IO goes through OpenDAL's `fs` service,
whose writer calls `sync_all()` on close.
#### Related
No earlier report found (searched issues/PRs for fsync, power loss,
durability, crash, empty metadata).
#1328 shows the same `TableMetadataWrapper` "Invalid JSON" error, caused
there by an S3 permission problem.
### Willingness to contribute
- [ ] I can contribute a fix for this bug independently
- [ ] I would be willing to contribute a fix for this bug with guidance from
the Iceberg community
- [ ] I cannot contribute a fix for this bug at this time
--
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]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]