Local warehouse (`file://`): data files, manifests and `metadata.json` are never fsynced before the catalog commit; after a power loss the table cannot be loaded
维护者通常 1 天内回复
还没有人认领这个 Issue。
- #4146 来自 @ghoshp83 —— 已关闭,未合并
评估
- 难度
- 5/5
- 预计耗时
- 一周以上
- 新手友好度
- 25/100
调研方向
Start with PyArrowLocalFileSystem.open_output_stream and PyArrowFile.create in pyiceberg/io/pyarrow.py, then trace Catalog._write_metadata and SqlCatalog.commit_table in pyiceberg/catalog/init.py and pyiceberg/catalog/sql.py. The report links open PR #4146, so work may already be underway. Done means files and directories referenced by a committed catalog pointer are durable before that pointer changes; verify with the provided strace and power-cut reproductions.
由索引模型根据 Issue 内容生成。
描述
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):
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:
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_streamonly 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-L256PyArrowFile.createreturns that stream for every file PyIceberg writes:
https://github.com/apache/iceberg-python/blob/eb30d8ae6ac4054f751b91cb04d4ae5026354cc2/pyiceberg/io/pyarrow.py#L357-L385Catalog._write_metadatawrites the new metadata JSON through it:
https://github.com/apache/iceberg-python/blob/eb30d8ae6ac4054f751b91cb04d4ae5026354cc2/pyiceberg/catalog/__init__.py#L1126-L1127SqlCatalog.commit_tablewrites the metadata file and then commits the newmetadata_location:
https://github.com/apache/iceberg-python/blob/eb30d8ae6ac4054f751b91cb04d4ae5026354cc2/pyiceberg/catalog/sql.py#L479-L560
(create_tablelikewise, L245-L296).filemaps toPyArrowFileIO, falling back toFsspecFileIO, 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
- 主要语言
- Python
- 星标
- 1.2k
- 派生
- 654
- 平均合并
- 2 天 11 小时
- 30 天内合并 PR
- 122
环境准备
- 没有 Dockerfile 或 Docker Compose 文件
- 有 Pull Request 模板
- 没有贡献指南
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
apache/iceberg-python 的其他 Issue
-
`inspect.files()` and the other file metadata tables raise `ArrowInvalid` when a snapshot has no manifests可能已有人在做 @YuangGao 于 10 天前认领。 未关闭kind:bug
难度 2/5 1-3 小时 新手友好度 72/100
apache/iceberg-python#4269 ·
维护者通常 1 天内回复
-
`inspect.entries()` raises TypeError when a data file has no column_sizes可能已有人在做 @Rahul-cs50 于 14 天前认领。 未关闭kind:bug
难度 2/5 1-3 小时 新手友好度 78/100
apache/iceberg-python#4186 · 2 条评论 ·
维护者通常 1 天内回复
-
`older_than` reads a naive datetime as UTC and expires snapshots it should keep可能已有人在做 @Rahul-cs50 于 13 天前认领。 未关闭kind:bug
难度 2/5 1-3 小时 新手友好度 72/100
apache/iceberg-python#4182 · 1 条评论 ·
维护者通常 1 天内回复
-
REST: Send empty first-page pageToken when rest-page-size is set可能已有人在做 @ehddnr301 于 32 天前认领。 未关闭kind:bug
难度 2/5 1-3 小时 新手友好度 82/100
apache/iceberg-python#4096 ·
维护者通常 1 天内回复
-
PyArrowFileIO: every small S3 write is a 3-request multipart upload; expose allow_delayed_open可能已有人在做 @David-Banquet 于 29 天前认领。 未关闭
难度 2/5 1-3 小时 新手友好度 70/100
apache/iceberg-python#4093 · 3 条评论 ·
维护者通常 1 天内回复
查看 apache/iceberg-python 的全部 Issue
相似的 Issue
-
bug
难度 2/5 1-3 小时 新手友好度 80/100
glossyio/traffic-monitor#312 ·
维护者通常 1 天内回复
-
documentation enhancement
难度 2/5 1-3 小时 新手友好度 76/100
es-ude/elastic-ai.creator#1046 ·
维护者通常 3 天内回复
-
feature request
难度 2/5 1-3 小时 新手友好度 65/100
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 72/100
confident-ai/deepteam#306 ·
维护者通常 1 天内回复
-
SslLibrary crashes with unhandled OSError: port/proto not found on non-standard SSL ports可能已有人在做 @himanchalkaushale 今天认领。 未关闭
难度 2/5 1-3 小时 新手友好度 82/100
OWASP/Nettacker#1884 · 1 条评论 ·
维护者通常 1 天内回复