abnobdoss commented on code in PR #3320:
URL: https://github.com/apache/iceberg-python/pull/3320#discussion_r3607157722
##########
pyiceberg/table/__init__.py:
##########
@@ -1043,17 +1084,82 @@ def commit_transaction(self) -> Table:
The table with the updates applied.
"""
if len(self._updates) > 0:
- self._requirements +=
(AssertTableUUID(uuid=self.table_metadata.table_uuid),)
- self._table._do_commit( # pylint: disable=W0212
- updates=self._updates,
- requirements=self._requirements,
+ properties = self._table.metadata.properties
+ num_retries: int = property_as_int( # type: ignore # The default
is set with non-None value.
+ properties, TableProperties.COMMIT_NUM_RETRIES,
TableProperties.COMMIT_NUM_RETRIES_DEFAULT
)
+ min_wait_ms: int = property_as_int( # type: ignore # The default
is set with non-None value.
+ properties, TableProperties.COMMIT_MIN_RETRY_WAIT_MS,
TableProperties.COMMIT_MIN_RETRY_WAIT_MS_DEFAULT
+ )
+ max_wait_ms: int = property_as_int( # type: ignore # The default
is set with non-None value.
+ properties, TableProperties.COMMIT_MAX_RETRY_WAIT_MS,
TableProperties.COMMIT_MAX_RETRY_WAIT_MS_DEFAULT
+ )
+ total_timeout_ms: int = property_as_int( # type: ignore # The
default is set with non-None value.
+ properties, TableProperties.COMMIT_TOTAL_RETRY_TIME_MS,
TableProperties.COMMIT_TOTAL_RETRY_TIME_MS_DEFAULT
+ )
+ start_time = time.monotonic()
+ self._requirements +=
(AssertTableUUID(uuid=self.table_metadata.table_uuid),)
+
+ try:
+ for attempt in range(num_retries + 1):
+ try:
+ self._table._do_commit( # pylint: disable=W0212
+ updates=self._updates,
+ requirements=self._requirements,
+ )
+ self._cleanup_uncommitted_manifests()
+ break
+ except CommitFailedException:
+ elapsed_ms = (time.monotonic() - start_time) * 1000
+ if attempt == num_retries or not
self._snapshot_producers or elapsed_ms >= total_timeout_ms:
+ raise
+
+ wait = min(min_wait_ms * (2**attempt), max_wait_ms)
+ jitter = random.uniform(0, 0.1 * wait)
+ logger.warning(
+ "Commit failed due to a concurrent update,
retrying (%s/%s) in %s ms",
+ attempt + 1,
+ num_retries,
+ round(wait + jitter),
+ )
+ time.sleep((wait + jitter) / 1000.0)
+
+ self._table.refresh()
+ self._rebuild_snapshot_updates()
+ except Exception:
+ for producer in self._snapshot_producers:
+ producer._clean_all_uncommitted()
+ raise
self._updates = ()
Review Comment:
Is a Transaction object meant to be safe to use after `commit_transaction`?
If it is then I think clearing `_snapshot_producers` on success and resetting
or invalidating the state on failure is missing.
Here are some test scenarios:
**Scenario 1:** a commit fails once for a transient reason (the kinds traced
in my `except Exception` comment), and the caller retries the same transaction.
The staged `AddSnapshotUpdate` survived the failure, its manifest list did not,
and the catalog accepts it:
```python
import pyarrow as pa
import pytest
from unittest.mock import patch
from pyiceberg.catalog.memory import InMemoryCatalog
from pyiceberg.exceptions import CommitFailedException
from pyiceberg.schema import Schema
from pyiceberg.types import LongType, NestedField
def test_reusing_a_failed_transaction_cannot_publish_deleted_files(tmp_path):
catalog = InMemoryCatalog("test", warehouse=tmp_path.as_uri())
catalog.create_namespace("default")
table = catalog.create_table(
"default.t",
schema=Schema(NestedField(1, "x", LongType(), required=False)),
properties={"commit.retry.num-retries": "0"},
)
table.append(pa.table({"x": [1]}))
tx = table.transaction()
tx.append(pa.table({"x": [2]}))
# The commit fails once for a transient reason.
with patch.object(catalog, "commit_table",
side_effect=CommitFailedException("transient")):
with pytest.raises(CommitFailedException):
tx.commit_transaction()
# The caller retries the same transaction, which was safe before this PR.
try:
tx.commit_transaction()
except Exception:
# Refusing reuse would be fine, as long as the table is untouched.
assert catalog.load_table("default.t").scan().to_arrow().to_pylist()
== [{"x": 1}]
return
# If the re-commit is accepted, the table must still be readable.
rows =
sorted(catalog.load_table("default.t").scan().to_arrow()["x"].to_pylist())
assert rows == [1, 2]
```
Fails with `FileNotFoundError` scanning the table: the catalog head points
at a manifest list the cleanup deleted.
**Scenario 2:** no failure, but reuse after a successful commit plus one
concurrent writer:
```python
def
test_reusing_a_committed_transaction_does_not_damage_the_first_commit(tmp_path):
catalog = InMemoryCatalog("test", warehouse=tmp_path.as_uri())
catalog.create_namespace("default")
table = catalog.create_table(
"default.t",
schema=Schema(NestedField(1, "x", LongType(), required=False)),
properties={"commit.retry.min-wait-ms": "1",
"commit.retry.max-wait-ms": "2"},
)
tx = table.transaction()
tx.append(pa.table({"x": [1, 2, 3]}))
tx.commit_transaction()
tx.append(pa.table({"x": [10, 20]}))
catalog.load_table("default.t").append(pa.table({"x": [100]})) # forces
a retry
tx.commit_transaction()
rows =
sorted(catalog.load_table("default.t").scan().to_arrow()["x"].to_pylist())
assert rows == [1, 2, 3, 10, 20, 100]
```
Also fails with `FileNotFoundError`: the retry replays the first producer,
which is still registered, so its batch is committed a second time and its
already-committed manifests get moved into the uncommitted list and deleted by
the post-success cleanup.
##########
pyiceberg/table/update/snapshot.py:
##########
@@ -499,8 +637,19 @@ def files_affected(self) -> bool:
"""Indicate if any manifest-entries can be dropped."""
return len(self._deleted_entries()) > 0
+ def _refresh_for_retry(self) -> None:
Review Comment:
Is it safe to clear the computed deletes on retry? Here's a scenario: a
writer plans `delete("x == 1")`, and before it commits, someone else appends a
file whose rows all match that predicate. The commit conflicts, the retry
replans the delete against the refreshed head, and the concurrent file is now a
delete target, so it gets dropped whole. The writer deletes rows it never saw,
under the isolation setting whose promise is that concurrent appends are
preserved:
```python
import pyarrow as pa
from pyiceberg.catalog.memory import InMemoryCatalog
from pyiceberg.schema import Schema
from pyiceberg.types import LongType, NestedField
def
test_snapshot_isolation_delete_does_not_remove_rows_it_never_saw(tmp_path):
catalog = InMemoryCatalog("test", warehouse=tmp_path.as_uri())
catalog.create_namespace("default")
table = catalog.create_table(
"default.t",
schema=Schema(NestedField(1, "x", LongType(), required=False)),
properties={
"write.delete.isolation-level": "snapshot",
"commit.retry.min-wait-ms": "1",
"commit.retry.max-wait-ms": "2",
},
)
table.append(pa.table({"x": [0, 1]}))
stale = catalog.load_table("default.t")
tx = stale.transaction()
tx.delete("x == 1")
# Lands after the delete was planned. The stale writer never saw this
row.
catalog.load_table("default.t").append(pa.table({"x": [1]}))
tx.commit_transaction()
rows =
sorted(catalog.load_table("default.t").scan().to_arrow()["x"].to_pylist())
assert rows == [0, 1] # fails: the concurrent row is gone
```
Maybe that is the intent, and a retried delete is supposed to re-execute
against the new table state and remove the concurrent row too. That reading
seems defensible as well, but I do not think the current behavior lands on it
either: only whole-file drops are replanned, while partial-file rewrites are
planned once in `Transaction.delete` and not recomputed. So a single concurrent
commit can end up half incorporated:
```python
import uuid
from pyiceberg.io.pyarrow import _dataframe_to_data_files
def test_retried_delete_treats_a_concurrent_commit_atomically(tmp_path):
catalog = InMemoryCatalog("test", warehouse=tmp_path.as_uri())
catalog.create_namespace("default")
table = catalog.create_table(
"default.t",
schema=Schema(NestedField(1, "x", LongType(), required=False)),
properties={
"write.delete.isolation-level": "snapshot",
"commit.retry.min-wait-ms": "1",
"commit.retry.max-wait-ms": "2",
},
)
table.append(pa.table({"x": [0, 1]}))
stale = catalog.load_table("default.t")
tx = stale.transaction()
tx.delete("x == 1")
# One concurrent commit carrying two files: [1] wholly matches, [1, 5]
partially.
b = catalog.load_table("default.t")
btx = b.transaction()
with btx.update_snapshot().fast_append() as append:
for df in (pa.table({"x": [1]}), pa.table({"x": [1, 5]})):
for f in _dataframe_to_data_files(
table_metadata=btx.table_metadata, io=b.io,
write_uuid=uuid.uuid4(), df=df
):
append.append_data_file(f)
btx.commit_transaction()
tx.commit_transaction()
final =
sorted(catalog.load_table("default.t").scan().to_arrow()["x"].to_pylist())
# The concurrent commit is atomic, so the delete may apply to all of it
or none of it.
assert final in ([0, 1, 1, 5], [0, 5]), f"half of the concurrent commit
was deleted: {final}"
```
The second test fails with `[0, 1, 5]`: one of the commit's matching rows
deleted, the other preserved, which I do not think either reading intends.
Since users cannot see or control which file a row lands in, the outcome of the
race is effectively random.
(`test_snapshot_isolation_allows_concurrent_append_delete` in this PR is the
partial-match instance of the same race and expects the row to survive.)
For reference, Java fixes the set of files a delete operates on at planning
time; retries revalidate but never replan, so concurrent commits are
consistently preserved under snapshot isolation. Would freezing the planned
file set here be an option? As far as I can tell it would make all of these
tests pass, including the existing one.
##########
pyiceberg/table/update/snapshot.py:
##########
@@ -91,19 +97,50 @@ def _new_manifest_list_file_name(snapshot_id: int, attempt:
int, commit_uuid: uu
return f"snap-{snapshot_id}-{attempt}-{commit_uuid}.avro"
+@dataclass(frozen=True)
+class CommitWindow:
+ """Tracks the commit range to validate against during retry.
+
+ base: The snapshot when the operation began (exclusive lower bound, fixed
across retries).
+ head: The branch HEAD snapshot after metadata refresh (inclusive upper
bound).
+ """
+
+ base: Snapshot | None
+ head: Snapshot | None
+
+ @classmethod
+ def resolve(cls, metadata: TableMetadata, base_id: int | None, branch: str
| None) -> CommitWindow:
+ """Resolve a CommitWindow from metadata, starting snapshot ID, and
target branch."""
+ head = metadata.snapshot_by_name(branch)
+ base = metadata.snapshot_by_id(base_id) if base_id is not None else
None
+ if base_id is not None and base is None:
+ raise ValidationException(f"Cannot find starting snapshot
{base_id}")
+ return cls(base=base, head=head)
+
+ def is_empty(self) -> bool:
Review Comment:
Is `base is None` safe to treat as an empty window? It also means the writer
started on a table with no snapshots, and if another writer lands the first
snapshot in the meantime, the retry skips validation entirely. Two jobs racing
to initialize the same table is a common bootstrap pattern:
```python
import pyarrow as pa
import pytest
from pyiceberg.catalog.memory import InMemoryCatalog
from pyiceberg.exceptions import ValidationException
from pyiceberg.schema import Schema
from pyiceberg.types import LongType, NestedField
def test_writer_that_started_on_an_empty_table_still_validates(tmp_path):
catalog = InMemoryCatalog("test", warehouse=tmp_path.as_uri())
catalog.create_namespace("default")
table = catalog.create_table(
"default.t",
schema=Schema(NestedField(1, "x", LongType(), required=False)),
properties={"commit.retry.min-wait-ms": "1",
"commit.retry.max-wait-ms": "2"},
)
stale = catalog.load_table("default.t")
tx = stale.transaction()
with pytest.warns(UserWarning): # the delete matches nothing on an
empty table
tx.overwrite(pa.table({"x": [2]}), overwrite_filter="x == 1")
# The first ever snapshot lands concurrently, with a row matching the
filter.
catalog.load_table("default.t").append(pa.table({"x": [1]}))
with pytest.raises(ValidationException):
tx.commit_transaction()
```
Fails because no exception is raised: the commit goes through unvalidated
and the table ends up as just `[2]`. The concurrent writer's row matched the
filter and was deleted, with no error on either side. Java treats a null
starting snapshot as validate against the entire history
(MergingSnapshotProducer walks every ancestor of the head), and treating a None
base the same way here would close this.
##########
pyiceberg/table/__init__.py:
##########
@@ -1043,17 +1084,82 @@ def commit_transaction(self) -> Table:
The table with the updates applied.
"""
if len(self._updates) > 0:
- self._requirements +=
(AssertTableUUID(uuid=self.table_metadata.table_uuid),)
- self._table._do_commit( # pylint: disable=W0212
- updates=self._updates,
- requirements=self._requirements,
+ properties = self._table.metadata.properties
+ num_retries: int = property_as_int( # type: ignore # The default
is set with non-None value.
+ properties, TableProperties.COMMIT_NUM_RETRIES,
TableProperties.COMMIT_NUM_RETRIES_DEFAULT
)
+ min_wait_ms: int = property_as_int( # type: ignore # The default
is set with non-None value.
+ properties, TableProperties.COMMIT_MIN_RETRY_WAIT_MS,
TableProperties.COMMIT_MIN_RETRY_WAIT_MS_DEFAULT
+ )
+ max_wait_ms: int = property_as_int( # type: ignore # The default
is set with non-None value.
+ properties, TableProperties.COMMIT_MAX_RETRY_WAIT_MS,
TableProperties.COMMIT_MAX_RETRY_WAIT_MS_DEFAULT
+ )
+ total_timeout_ms: int = property_as_int( # type: ignore # The
default is set with non-None value.
+ properties, TableProperties.COMMIT_TOTAL_RETRY_TIME_MS,
TableProperties.COMMIT_TOTAL_RETRY_TIME_MS_DEFAULT
+ )
+ start_time = time.monotonic()
+ self._requirements +=
(AssertTableUUID(uuid=self.table_metadata.table_uuid),)
+
+ try:
+ for attempt in range(num_retries + 1):
+ try:
+ self._table._do_commit( # pylint: disable=W0212
+ updates=self._updates,
+ requirements=self._requirements,
+ )
+ self._cleanup_uncommitted_manifests()
+ break
+ except CommitFailedException:
+ elapsed_ms = (time.monotonic() - start_time) * 1000
+ if attempt == num_retries or not
self._snapshot_producers or elapsed_ms >= total_timeout_ms:
+ raise
+
+ wait = min(min_wait_ms * (2**attempt), max_wait_ms)
+ jitter = random.uniform(0, 0.1 * wait)
+ logger.warning(
+ "Commit failed due to a concurrent update,
retrying (%s/%s) in %s ms",
+ attempt + 1,
+ num_retries,
+ round(wait + jitter),
+ )
+ time.sleep((wait + jitter) / 1000.0)
+
+ self._table.refresh()
+ self._rebuild_snapshot_updates()
+ except Exception:
Review Comment:
I'm a bit wary of the bare `except Exception` here, so I traced what can
actually reach it. I count three groups with very different meanings:
1. The commit definitely did not happen: `ValidationException` from the
conflict checks during rebuild, IO errors while writing the next attempt's
manifests.
2. The outcome is unknown: `CommitStateUnknownException` (REST raises it for
500/502/504, which is what a gateway timeout looks like when the backend
finished the commit), raw `requests` connection errors and timeouts (nothing in
the client catches these), and SQLAlchemy `OperationalError`, which covers both
"database locked" and "connection dropped after COMMIT".
3. The commit definitely happened: a pydantic `ValidationError` while
parsing the 200 commit response, and the `ValueError` from `_check_uuid`, both
raised after `commit_table` already succeeded.
The handler treats all three the same way and deletes every written manifest
and manifest list. For groups 2 and 3 those files can already be referenced by
the catalog's current snapshot, and deleting them makes the table unreadable
for every reader, not just this writer.
```python
import pyarrow as pa
import pytest
from unittest.mock import patch
from pyiceberg.catalog.memory import InMemoryCatalog
from pyiceberg.exceptions import CommitStateUnknownException
from pyiceberg.schema import Schema
from pyiceberg.types import LongType, NestedField
def test_unknown_commit_outcome_keeps_the_committed_files(tmp_path):
catalog = InMemoryCatalog("test", warehouse=tmp_path.as_uri())
catalog.create_namespace("default")
table = catalog.create_table(
"default.t", schema=Schema(NestedField(1, "x", LongType(),
required=False))
)
real_commit = catalog.commit_table
def commit_then_lose_response(*args, **kwargs):
real_commit(*args, **kwargs)
raise CommitStateUnknownException("response lost after the commit
landed")
with patch.object(catalog, "commit_table",
side_effect=commit_then_lose_response):
with pytest.raises(CommitStateUnknownException):
table.append(pa.table({"x": [1]}))
# The commit landed, so the table must still be readable.
assert catalog.load_table("default.t").scan().to_arrow().to_pylist() ==
[{"x": 1}]
```
Fails with `FileNotFoundError` on the committed snapshot's manifest list.
I think the safe rule is to invert the default: only delete files for
exceptions that guarantee the commit did not happen (`CommitFailedException`,
`ValidationException`) and keep them for everything else. Orphan files are
recoverable, a deleted manifest list behind a live snapshot is not. That
matches Java, which rethrows `CommitStateUnknownException` before any cleanup
runs (SnapshotProducer.java).
Longer term, group 2 can only be classified truthfully by asking the catalog
whether the commit landed, the way Java's checkCommitStatus polls for its own
metadata location. That needs a stable snapshot id across attempts, which I
have raised separately.
##########
pyiceberg/table/__init__.py:
##########
@@ -1043,17 +1084,82 @@ def commit_transaction(self) -> Table:
The table with the updates applied.
"""
if len(self._updates) > 0:
- self._requirements +=
(AssertTableUUID(uuid=self.table_metadata.table_uuid),)
- self._table._do_commit( # pylint: disable=W0212
- updates=self._updates,
- requirements=self._requirements,
+ properties = self._table.metadata.properties
+ num_retries: int = property_as_int( # type: ignore # The default
is set with non-None value.
+ properties, TableProperties.COMMIT_NUM_RETRIES,
TableProperties.COMMIT_NUM_RETRIES_DEFAULT
)
+ min_wait_ms: int = property_as_int( # type: ignore # The default
is set with non-None value.
+ properties, TableProperties.COMMIT_MIN_RETRY_WAIT_MS,
TableProperties.COMMIT_MIN_RETRY_WAIT_MS_DEFAULT
+ )
+ max_wait_ms: int = property_as_int( # type: ignore # The default
is set with non-None value.
+ properties, TableProperties.COMMIT_MAX_RETRY_WAIT_MS,
TableProperties.COMMIT_MAX_RETRY_WAIT_MS_DEFAULT
+ )
+ total_timeout_ms: int = property_as_int( # type: ignore # The
default is set with non-None value.
+ properties, TableProperties.COMMIT_TOTAL_RETRY_TIME_MS,
TableProperties.COMMIT_TOTAL_RETRY_TIME_MS_DEFAULT
+ )
+ start_time = time.monotonic()
+ self._requirements +=
(AssertTableUUID(uuid=self.table_metadata.table_uuid),)
+
+ try:
+ for attempt in range(num_retries + 1):
Review Comment:
With `commit.retry.num-retries=-1` this is `range(0)`: no commit is
attempted, no error is raised, the staged updates are cleared after the loop,
and the call returns as success. Since this is a table property, one writer
setting it turns every writer's commits into silent no-ops. Some tools use -1
to mean retry forever, so it is a plausible value.
```python
import pyarrow as pa
from pyiceberg.catalog.memory import InMemoryCatalog
from pyiceberg.schema import Schema
from pyiceberg.types import LongType, NestedField
def test_invalid_retry_count_does_not_silently_skip_the_commit(tmp_path):
catalog = InMemoryCatalog("test", warehouse=tmp_path.as_uri())
catalog.create_namespace("default")
table = catalog.create_table(
"default.t",
schema=Schema(NestedField(1, "x", LongType(), required=False)),
properties={"commit.retry.num-retries": "-1"},
)
table.append(pa.table({"x": [1]}))
assert catalog.load_table("default.t").scan().to_arrow().to_pylist() ==
[{"x": 1}]
```
Fails with an empty table. Validating the property when read (raise on
negatives, or clamp to zero) makes this a loud config error instead of silent
data loss. Java runs one attempt even for a negative value.
##########
pyiceberg/table/update/snapshot.py:
##########
@@ -353,11 +400,101 @@ def new_manifest_output(self) -> OutputFile:
location_provider = self._transaction._table.location_provider()
file_name =
_new_manifest_file_name(num=next(self._manifest_num_counter),
commit_uuid=self.commit_uuid)
file_path = location_provider.new_metadata_location(file_name)
+ self._written_manifests.append(file_path)
return self._io.new_output(file_path)
def fetch_manifest_entry(self, manifest: ManifestFile, discard_deleted:
bool = True) -> list[ManifestEntry]:
return manifest.fetch_manifest_entry(io=self._io,
discard_deleted=discard_deleted)
+ def commit(self) -> None:
+ self._transaction._register_snapshot_producer(self)
+ super().commit()
+
+ def _cleanup_uncommitted(self) -> None:
+ """Delete manifest files and manifest lists from failed retry
attempts."""
+ for path in self._uncommitted_manifests:
+ try:
+ self._io.delete(path)
+ except Exception:
+ logger.warning("Failed to delete uncommitted manifest: %s",
path, exc_info=True)
+ self._uncommitted_manifests.clear()
+ # Delete all manifest lists except the last one (which the committed
snapshot references)
+ if len(self._written_manifest_lists) > 1:
+ for path in self._written_manifest_lists[:-1]:
+ try:
+ self._io.delete(path)
+ except Exception:
+ logger.warning("Failed to delete uncommitted manifest
list: %s", path, exc_info=True)
+ self._written_manifest_lists = self._written_manifest_lists[-1:]
+
+ def _clean_all_uncommitted(self) -> None:
+ """Clean up all manifests and manifest lists on abort."""
+ for path in itertools.chain(self._uncommitted_manifests,
self._written_manifests):
+ try:
+ self._io.delete(path)
+ except Exception:
+ logger.warning("Failed to delete uncommitted manifest: %s",
path, exc_info=True)
+ for path in self._written_manifest_lists:
+ try:
+ self._io.delete(path)
+ except Exception:
+ logger.warning("Failed to delete uncommitted manifest list:
%s", path, exc_info=True)
+ self._uncommitted_manifests.clear()
+ self._written_manifests.clear()
+ self._written_manifest_lists.clear()
+
+ def _refresh_for_retry(self) -> None:
+ """Reset state for a retry attempt with refreshed metadata."""
+ self._uncommitted_manifests.extend(self._written_manifests)
+ self._written_manifests.clear()
+ self._parent_snapshot_id = self._current_branch_head_id()
+ self._snapshot_id = self._transaction.table_metadata.new_snapshot_id()
Review Comment:
On a similar note to my comment on the bare `except Exception` in
`Transaction.commit_transaction`:
A commit call over a network has three possible outcomes: it applied and we
got the response, it did not apply and we got an error, or it applied and we
still got an error because the response was lost. The retry loop only models
the first two. In the third case it refreshes and commits the same data again,
like retrying a payment without an idempotency key.
The snapshot id could be that idempotency key, but this line mints a new one
on every attempt, so the client has no way to recognize its own commit in the
refreshed metadata. The third case is not hypothetical: Glue's boto client
silently resends `UpdateTable` on connection errors and the resend's version
conflict is reported as `CommitFailedException`, and a retrying proxy in front
of a REST catalog does the same via a 409.
```python
import pyarrow as pa
from unittest.mock import patch
from pyiceberg.catalog.memory import InMemoryCatalog
from pyiceberg.exceptions import CommitFailedException
from pyiceberg.schema import Schema
from pyiceberg.types import LongType, NestedField
def
test_commit_that_landed_but_was_reported_failed_is_not_committed_twice(tmp_path):
catalog = InMemoryCatalog("test", warehouse=tmp_path.as_uri())
catalog.create_namespace("default")
table = catalog.create_table(
"default.t",
schema=Schema(NestedField(1, "x", LongType(), required=False)),
properties={"commit.retry.min-wait-ms": "1",
"commit.retry.max-wait-ms": "2"},
)
real_commit = catalog.commit_table
calls = []
def commit_then_report_conflict(*args, **kwargs):
result = real_commit(*args, **kwargs)
calls.append(1)
if len(calls) == 1:
raise CommitFailedException("transport layer retried; first
response was lost")
return result
with patch.object(catalog, "commit_table",
side_effect=commit_then_report_conflict):
table.append(pa.table({"x": [1]}))
assert catalog.load_table("default.t").scan().to_arrow().to_pylist() ==
[{"x": 1}]
```
Fails with `FileNotFoundError`: the data is committed twice, and the
post-success cleanup then deletes manifests the first committed snapshot
references.
Java generates the snapshot id once, reuses it for every attempt, and checks
the refreshed metadata for it before re-committing (SnapshotProducer.java).
Doing the same here would close this for every catalog at once.
--
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]