Write support - #41

Merged
Fokko merged 71 commits into
apache:mainfrom
Fokko:fd-write
Jan 18, 2024
Merged

Write support#41
Fokko merged 71 commits into
apache:mainfrom
Fokko:fd-write

Conversation

@Fokko

@FokkoFokko commented Oct 4, 2023

Copy link
Copy Markdown
Contributor

Experimental branch to implement writing. Much of the changes here will be split out into small manageable PRs.

Resolves#181
Resolves#23

For V1 and V2 there are some differences that are hard
to enforce without this:
- `1: snapshot_id` is required for V1, optional for V2
- `105: block_size_in_bytes` needs to be written for V1, but omitted for V2 (this leverages the `write-default`).
- `3: sequence_number` and `4: file_sequence_number` can be omited for V1.
Everything that we read, we map it to V2. However, when writing
we also want to be compliant with the V1 spec, and this is where
the writer tree comes in since we construct a tree for V1 or V2.
@FokkoFokko added this to the PyIceberg 0.6.0 release milestone Oct 9, 2023
@samplec0de

Copy link
Copy Markdown

Very relevant! I'm looking forward to it, thank you!

@FokkoFokko mentioned this pull request Oct 11, 2023
4 tasks
Comment threadmkdocs/docs/api.md

When reading the table `tbl.scan().to_arrow()` you can see that `Groningen` is now also part of the table:

```

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While working on this, I also checked the field-ids:

parq 00000-0-27345354-67b8-4861-95ca-c2de9dc8d3fe.parquet --schema
# Schema <pyarrow._parquet.ParquetSchema object at 0x11eca2e00>
required group field_id=-1 schema {
optional binary field_id=1 city (String);
optional double field_id=2 lat;
optional double field_id=3 long;
}

Comment threadmkdocs/docs/api.md
schema = Schema(
NestedField(1, "city", StringType(), required=False),
NestedField(2, "lat", DoubleType(), required=False),
NestedField(3, "long", DoubleType(), required=False),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't required=False the default?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, the default is the more strict True. I've set it to False because PyArrow produces nullable fields by default

Comment threadpyiceberg/table/__init__.py Outdated
if len(self.sort_order().fields) > 0:
raise ValueError("Cannot write to tables with a sort-order")

snapshot_id = self.new_snapshot_id()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this can be handled inside of _MergeAppend since it has the table.

Comment threadpyiceberg/table/__init__.py Outdated
snapshot_id = self.new_snapshot_id()

data_files = _dataframe_to_data_files(self, df=df)
merge = _MergeAppend(operation=Operation.APPEND, table=self, snapshot_id=snapshot_id)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this really a "merge append" if the operation may be overwrite? You might consider using _MergingCommit or _MergingSnapshotProducer (if you want to follow the Java convention).

Comment threadpyiceberg/table/__init__.py
for entry in manifest.fetch_manifest_entry(self._table.io, discard_deleted=True)
]

list_of_entries = executor.map(_get_entries, previous_snapshot.manifests(self._table.io))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may be a good idea to defensively use only data manifests here instead of all manifests.

status=ManifestEntryStatus.DELETED,
snapshot_id=entry.snapshot_id,
data_sequence_number=entry.data_sequence_number,
file_sequence_number=entry.file_sequence_number,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good.

Comment threadpyiceberg/table/__init__.py Outdated
raise ValueError(f"Not implemented for: {self._operation}")

def _manifests(self) -> List[ManifestFile]:
manifests = []

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Since this is empty, it looks like this is just the newly created manifests. It may be a good idea to name this new_manifests.

Comment threadpyiceberg/table/__init__.py Outdated
summary=Summary(operation=self._operation, **self._summary()),
previous_summary=previous_snapshot.summary if previous_snapshot is not None else None,
truncate_full_table=self._operation == Operation.OVERWRITE,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this block could be moved to _summary so that it produces the correct summary without the need to modify it afterward. That seems a bit cleaner to me, rather than having a two-step process split across methods.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's a great point 👍

if self._operation == Operation.APPEND and previous_snapshot is not None:
# In case we want to append, just add the existing manifests
writer.add_manifests(previous_snapshot.manifests(io=self._table.io))
writer.add_manifests(new_manifests)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the note above, I think it would be cleaner to have _manifests produce the complete set of manifests, not just the replacement ones. That method already relies on _deleted_entries to produce deletes, so it may as well also be responsible for checking whether to include the existing manifests.

Another option is to make _manifests produce just manifests for the appended files and handle deletes separately, but it looks like your approach here is to create just one manifest with both deletes and appends.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great suggestion. I've moved all the logic to _manifests()

Comment threadpyiceberg/table/__init__.py Outdated
)

for delete_entry in deleted_entries:
writer.add_entry(delete_entry)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this approach works fine, but I want to point out that there are drawbacks to writing the deletes in the same manifest:

  1. A reader has to load all of the deletes, even though the files aren't useful. If they are in a separate manifest, readers can filter out manifests that have no EXISTING or ADDED data files.
  2. Manifests with no data files can be removed in future append commits.
  3. This write is single-threaded. In the Java implementation, we produce a manifest of deleted data files for each existing manifest. That allows us to parallelize the operation.

Here's the logic we use to drop manifests that aren't needed on the Java side when producing the new list of manifests:

// only keep manifests that have live data files or that were written by this commitPredicate<ManifestFile> shouldKeep =
manifest ->
manifest.hasAddedFiles()
|| manifest.hasExistingFiles()
|| manifest.snapshotId() == snapshotId();

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I missed this one, thanks for suggesting it! 👍 I've split out the ADDED, EXISTING, and DELETE entries into separate manifests that write in parallel.

Comment threadmkdocs/docs/api.md

## Write support

With PyIceberg 0.6.0 write support is added through Arrow. Let's consider an Arrow Table:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this example! Made it really easy to test out.

The example works great cut & pasted into a REPL. I also tested modifications to the dataframe schema passed to append and it does the right thing. I get a schema error for a few cases:

  • Missing column long
  • Type mismatch string instead of double
  • Extra column country

Looks like Arrow requires that the schema matches, which is great.

It would be nice to allow some type promotion in the future. I'm not sure whether arrow would automatically write floats into double columns, for example. I would also like to make sure we have better error messages, not just "ValueError: Table schema does not match schema used to create file: ...". Those will be good follow ups.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think this ties into the work that @syun64 is doing where we have to make sure that we map the fields correctly, and then I think we can add options to massage the Arrow schema into the Iceberg one (which should be leading).

We can create a visitorWithPartner that will see if the promotions are possible. One that comes to my mind directly, is checking if there are any nulls. Arrow marks the schemas as nullable by default, while there are no nulls.

@rdblue

Copy link
Copy Markdown
Contributor

@Fokko, this works great and I don't see any blockers so I've approved it.

I think there are a few things to consider in terms of how we want to do this moving forward (whether to use separate manifests for example) but we can get this in and iterate from there. It also looks like this is pretty close to being able to run the overwrite filter, too! Great work.

@Fokko
Fokko merged commit 8f7927b into apache:mainJan 18, 2024
@Fokko
Fokko deleted the fd-write branch January 18, 2024 10:19
@Fokko

Copy link
Copy Markdown
ContributorAuthor

Many thanks again for the great review @rdblue. I went forward and merged it 🙌 Probably we'll improve a bit more on the code-style structure when we add #270 We went a bit back and forth a couple of times. Great having this in 🚀

}

TABLE_SCHEMA = Schema(
NestedField(field_id=1, name="bool", field_type=BooleanType(), required=False),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Fokko I have tested the write I see if we make Table schema for any field required =True example NestedField(field_id=13, name="fixed", field_type=FixedType(16), required=True)

ValueError: Table schema does not match schema used to create file:

I always fails 1102 if not table.schema.equals(self.schema, check_metadata=False):
1103 msg = ('Table schema does not match schema used to create file: '
1104 '\ntable:\n{!s} vs. \nfile:\n{!s}'
1105 .format(table.schema, self.schema))
-> 1106 raise ValueError(msg)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PyArrow fields by default are nullable, which matches all the nested fields in TABLE_SCHEMA. If you want to test against non-nullable fields, then arrow_table_with_null or whatever other pyarrow table you are instantiating should have nullable=False for the field that has required=True.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sebpretzer thanks for the clarification. Tested it work as expected

@mkleinbort-ic

Copy link
Copy Markdown

Is there an ETA for write functionality in the released version?

@EternalDeiwos

Copy link
Copy Markdown
Contributor

Check the attached milestone for progress. When those issues are resolved it will be ready for release.

@sungwy

Copy link
Copy Markdown
Collaborator

Hi @mkleinbort-ic we've just started voting on the first release candidate that incorporates this change

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Uploading Data to Iceberg Python write support

11 participants

@Fokko@samplec0de@rdblue@mkleinbort-ic@EternalDeiwos@sungwy@robtandy@asheeshgarg@sebpretzer@HonahX@jqin61
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Write support - #41

Merged
Fokko merged 71 commits into
apache:mainfrom
Fokko:fd-write
Jan 18, 2024
Merged

Write support#41
Fokko merged 71 commits into
apache:mainfrom
Fokko:fd-write

Conversation

@Fokko

@FokkoFokko commented Oct 4, 2023

Copy link
Copy Markdown
Contributor

Experimental branch to implement writing. Much of the changes here will be split out into small manageable PRs.

Resolves#181
Resolves#23

For V1 and V2 there are some differences that are hard
to enforce without this:
- `1: snapshot_id` is required for V1, optional for V2
- `105: block_size_in_bytes` needs to be written for V1, but omitted for V2 (this leverages the `write-default`).
- `3: sequence_number` and `4: file_sequence_number` can be omited for V1.
Everything that we read, we map it to V2. However, when writing
we also want to be compliant with the V1 spec, and this is where
the writer tree comes in since we construct a tree for V1 or V2.
@FokkoFokko added this to the PyIceberg 0.6.0 release milestone Oct 9, 2023
@samplec0de

Copy link
Copy Markdown

Very relevant! I'm looking forward to it, thank you!

@FokkoFokko mentioned this pull request Oct 11, 2023
4 tasks
Comment threadmkdocs/docs/api.md

When reading the table `tbl.scan().to_arrow()` you can see that `Groningen` is now also part of the table:

```

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While working on this, I also checked the field-ids:

parq 00000-0-27345354-67b8-4861-95ca-c2de9dc8d3fe.parquet --schema
# Schema <pyarrow._parquet.ParquetSchema object at 0x11eca2e00>
required group field_id=-1 schema {
optional binary field_id=1 city (String);
optional double field_id=2 lat;
optional double field_id=3 long;
}

Comment threadmkdocs/docs/api.md
schema = Schema(
NestedField(1, "city", StringType(), required=False),
NestedField(2, "lat", DoubleType(), required=False),
NestedField(3, "long", DoubleType(), required=False),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't required=False the default?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, the default is the more strict True. I've set it to False because PyArrow produces nullable fields by default

Comment threadpyiceberg/table/__init__.py Outdated
if len(self.sort_order().fields) > 0:
raise ValueError("Cannot write to tables with a sort-order")

snapshot_id = self.new_snapshot_id()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this can be handled inside of _MergeAppend since it has the table.

Comment threadpyiceberg/table/__init__.py Outdated
snapshot_id = self.new_snapshot_id()

data_files = _dataframe_to_data_files(self, df=df)
merge = _MergeAppend(operation=Operation.APPEND, table=self, snapshot_id=snapshot_id)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this really a "merge append" if the operation may be overwrite? You might consider using _MergingCommit or _MergingSnapshotProducer (if you want to follow the Java convention).

Comment threadpyiceberg/table/__init__.py
for entry in manifest.fetch_manifest_entry(self._table.io, discard_deleted=True)
]

list_of_entries = executor.map(_get_entries, previous_snapshot.manifests(self._table.io))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may be a good idea to defensively use only data manifests here instead of all manifests.

status=ManifestEntryStatus.DELETED,
snapshot_id=entry.snapshot_id,
data_sequence_number=entry.data_sequence_number,
file_sequence_number=entry.file_sequence_number,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good.

Comment threadpyiceberg/table/__init__.py Outdated
raise ValueError(f"Not implemented for: {self._operation}")

def _manifests(self) -> List[ManifestFile]:
manifests = []

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Since this is empty, it looks like this is just the newly created manifests. It may be a good idea to name this new_manifests.

Comment threadpyiceberg/table/__init__.py Outdated
summary=Summary(operation=self._operation, **self._summary()),
previous_summary=previous_snapshot.summary if previous_snapshot is not None else None,
truncate_full_table=self._operation == Operation.OVERWRITE,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this block could be moved to _summary so that it produces the correct summary without the need to modify it afterward. That seems a bit cleaner to me, rather than having a two-step process split across methods.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's a great point 👍

if self._operation == Operation.APPEND and previous_snapshot is not None:
# In case we want to append, just add the existing manifests
writer.add_manifests(previous_snapshot.manifests(io=self._table.io))
writer.add_manifests(new_manifests)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the note above, I think it would be cleaner to have _manifests produce the complete set of manifests, not just the replacement ones. That method already relies on _deleted_entries to produce deletes, so it may as well also be responsible for checking whether to include the existing manifests.

Another option is to make _manifests produce just manifests for the appended files and handle deletes separately, but it looks like your approach here is to create just one manifest with both deletes and appends.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great suggestion. I've moved all the logic to _manifests()

Comment threadpyiceberg/table/__init__.py Outdated
)

for delete_entry in deleted_entries:
writer.add_entry(delete_entry)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this approach works fine, but I want to point out that there are drawbacks to writing the deletes in the same manifest:

  1. A reader has to load all of the deletes, even though the files aren't useful. If they are in a separate manifest, readers can filter out manifests that have no EXISTING or ADDED data files.
  2. Manifests with no data files can be removed in future append commits.
  3. This write is single-threaded. In the Java implementation, we produce a manifest of deleted data files for each existing manifest. That allows us to parallelize the operation.

Here's the logic we use to drop manifests that aren't needed on the Java side when producing the new list of manifests:

// only keep manifests that have live data files or that were written by this commitPredicate<ManifestFile> shouldKeep =
manifest ->
manifest.hasAddedFiles()
|| manifest.hasExistingFiles()
|| manifest.snapshotId() == snapshotId();

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I missed this one, thanks for suggesting it! 👍 I've split out the ADDED, EXISTING, and DELETE entries into separate manifests that write in parallel.

Comment threadmkdocs/docs/api.md

## Write support

With PyIceberg 0.6.0 write support is added through Arrow. Let's consider an Arrow Table:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this example! Made it really easy to test out.

The example works great cut & pasted into a REPL. I also tested modifications to the dataframe schema passed to append and it does the right thing. I get a schema error for a few cases:

  • Missing column long
  • Type mismatch string instead of double
  • Extra column country

Looks like Arrow requires that the schema matches, which is great.

It would be nice to allow some type promotion in the future. I'm not sure whether arrow would automatically write floats into double columns, for example. I would also like to make sure we have better error messages, not just "ValueError: Table schema does not match schema used to create file: ...". Those will be good follow ups.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think this ties into the work that @syun64 is doing where we have to make sure that we map the fields correctly, and then I think we can add options to massage the Arrow schema into the Iceberg one (which should be leading).

We can create a visitorWithPartner that will see if the promotions are possible. One that comes to my mind directly, is checking if there are any nulls. Arrow marks the schemas as nullable by default, while there are no nulls.

@rdblue

Copy link
Copy Markdown
Contributor

@Fokko, this works great and I don't see any blockers so I've approved it.

I think there are a few things to consider in terms of how we want to do this moving forward (whether to use separate manifests for example) but we can get this in and iterate from there. It also looks like this is pretty close to being able to run the overwrite filter, too! Great work.

@Fokko
Fokko merged commit 8f7927b into apache:mainJan 18, 2024
@Fokko
Fokko deleted the fd-write branch January 18, 2024 10:19
@Fokko

Copy link
Copy Markdown
ContributorAuthor

Many thanks again for the great review @rdblue. I went forward and merged it 🙌 Probably we'll improve a bit more on the code-style structure when we add #270 We went a bit back and forth a couple of times. Great having this in 🚀

}

TABLE_SCHEMA = Schema(
NestedField(field_id=1, name="bool", field_type=BooleanType(), required=False),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Fokko I have tested the write I see if we make Table schema for any field required =True example NestedField(field_id=13, name="fixed", field_type=FixedType(16), required=True)

ValueError: Table schema does not match schema used to create file:

I always fails 1102 if not table.schema.equals(self.schema, check_metadata=False):
1103 msg = ('Table schema does not match schema used to create file: '
1104 '\ntable:\n{!s} vs. \nfile:\n{!s}'
1105 .format(table.schema, self.schema))
-> 1106 raise ValueError(msg)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PyArrow fields by default are nullable, which matches all the nested fields in TABLE_SCHEMA. If you want to test against non-nullable fields, then arrow_table_with_null or whatever other pyarrow table you are instantiating should have nullable=False for the field that has required=True.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sebpretzer thanks for the clarification. Tested it work as expected

@mkleinbort-ic

Copy link
Copy Markdown

Is there an ETA for write functionality in the released version?

@EternalDeiwos

Copy link
Copy Markdown
Contributor

Check the attached milestone for progress. When those issues are resolved it will be ready for release.

@sungwy

Copy link
Copy Markdown
Collaborator

Hi @mkleinbort-ic we've just started voting on the first release candidate that incorporates this change

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Uploading Data to Iceberg Python write support

11 participants

@Fokko@samplec0de@rdblue@mkleinbort-ic@EternalDeiwos@sungwy@robtandy@asheeshgarg@sebpretzer@HonahX@jqin61
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Write support - #41

Merged
Fokko merged 71 commits into
apache:mainfrom
Fokko:fd-write
Jan 18, 2024
Merged

Write support#41
Fokko merged 71 commits into
apache:mainfrom
Fokko:fd-write

Conversation

@Fokko

@FokkoFokko commented Oct 4, 2023

Copy link
Copy Markdown
Contributor

Experimental branch to implement writing. Much of the changes here will be split out into small manageable PRs.

Resolves#181
Resolves#23

For V1 and V2 there are some differences that are hard
to enforce without this:
- `1: snapshot_id` is required for V1, optional for V2
- `105: block_size_in_bytes` needs to be written for V1, but omitted for V2 (this leverages the `write-default`).
- `3: sequence_number` and `4: file_sequence_number` can be omited for V1.
Everything that we read, we map it to V2. However, when writing
we also want to be compliant with the V1 spec, and this is where
the writer tree comes in since we construct a tree for V1 or V2.
@FokkoFokko added this to the PyIceberg 0.6.0 release milestone Oct 9, 2023
@samplec0de

Copy link
Copy Markdown

Very relevant! I'm looking forward to it, thank you!

@FokkoFokko mentioned this pull request Oct 11, 2023
4 tasks
Comment threadmkdocs/docs/api.md

When reading the table `tbl.scan().to_arrow()` you can see that `Groningen` is now also part of the table:

```

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While working on this, I also checked the field-ids:

parq 00000-0-27345354-67b8-4861-95ca-c2de9dc8d3fe.parquet --schema
# Schema <pyarrow._parquet.ParquetSchema object at 0x11eca2e00>
required group field_id=-1 schema {
optional binary field_id=1 city (String);
optional double field_id=2 lat;
optional double field_id=3 long;
}

Comment threadmkdocs/docs/api.md
schema = Schema(
NestedField(1, "city", StringType(), required=False),
NestedField(2, "lat", DoubleType(), required=False),
NestedField(3, "long", DoubleType(), required=False),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't required=False the default?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, the default is the more strict True. I've set it to False because PyArrow produces nullable fields by default

Comment threadpyiceberg/table/__init__.py Outdated
if len(self.sort_order().fields) > 0:
raise ValueError("Cannot write to tables with a sort-order")

snapshot_id = self.new_snapshot_id()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this can be handled inside of _MergeAppend since it has the table.

Comment threadpyiceberg/table/__init__.py Outdated
snapshot_id = self.new_snapshot_id()

data_files = _dataframe_to_data_files(self, df=df)
merge = _MergeAppend(operation=Operation.APPEND, table=self, snapshot_id=snapshot_id)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this really a "merge append" if the operation may be overwrite? You might consider using _MergingCommit or _MergingSnapshotProducer (if you want to follow the Java convention).

Comment threadpyiceberg/table/__init__.py
for entry in manifest.fetch_manifest_entry(self._table.io, discard_deleted=True)
]

list_of_entries = executor.map(_get_entries, previous_snapshot.manifests(self._table.io))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may be a good idea to defensively use only data manifests here instead of all manifests.

status=ManifestEntryStatus.DELETED,
snapshot_id=entry.snapshot_id,
data_sequence_number=entry.data_sequence_number,
file_sequence_number=entry.file_sequence_number,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good.

Comment threadpyiceberg/table/__init__.py Outdated
raise ValueError(f"Not implemented for: {self._operation}")

def _manifests(self) -> List[ManifestFile]:
manifests = []

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Since this is empty, it looks like this is just the newly created manifests. It may be a good idea to name this new_manifests.

Comment threadpyiceberg/table/__init__.py Outdated
summary=Summary(operation=self._operation, **self._summary()),
previous_summary=previous_snapshot.summary if previous_snapshot is not None else None,
truncate_full_table=self._operation == Operation.OVERWRITE,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this block could be moved to _summary so that it produces the correct summary without the need to modify it afterward. That seems a bit cleaner to me, rather than having a two-step process split across methods.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's a great point 👍

if self._operation == Operation.APPEND and previous_snapshot is not None:
# In case we want to append, just add the existing manifests
writer.add_manifests(previous_snapshot.manifests(io=self._table.io))
writer.add_manifests(new_manifests)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the note above, I think it would be cleaner to have _manifests produce the complete set of manifests, not just the replacement ones. That method already relies on _deleted_entries to produce deletes, so it may as well also be responsible for checking whether to include the existing manifests.

Another option is to make _manifests produce just manifests for the appended files and handle deletes separately, but it looks like your approach here is to create just one manifest with both deletes and appends.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great suggestion. I've moved all the logic to _manifests()

Comment threadpyiceberg/table/__init__.py Outdated
)

for delete_entry in deleted_entries:
writer.add_entry(delete_entry)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this approach works fine, but I want to point out that there are drawbacks to writing the deletes in the same manifest:

  1. A reader has to load all of the deletes, even though the files aren't useful. If they are in a separate manifest, readers can filter out manifests that have no EXISTING or ADDED data files.
  2. Manifests with no data files can be removed in future append commits.
  3. This write is single-threaded. In the Java implementation, we produce a manifest of deleted data files for each existing manifest. That allows us to parallelize the operation.

Here's the logic we use to drop manifests that aren't needed on the Java side when producing the new list of manifests:

// only keep manifests that have live data files or that were written by this commitPredicate<ManifestFile> shouldKeep =
manifest ->
manifest.hasAddedFiles()
|| manifest.hasExistingFiles()
|| manifest.snapshotId() == snapshotId();

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I missed this one, thanks for suggesting it! 👍 I've split out the ADDED, EXISTING, and DELETE entries into separate manifests that write in parallel.

Comment threadmkdocs/docs/api.md

## Write support

With PyIceberg 0.6.0 write support is added through Arrow. Let's consider an Arrow Table:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this example! Made it really easy to test out.

The example works great cut & pasted into a REPL. I also tested modifications to the dataframe schema passed to append and it does the right thing. I get a schema error for a few cases:

  • Missing column long
  • Type mismatch string instead of double
  • Extra column country

Looks like Arrow requires that the schema matches, which is great.

It would be nice to allow some type promotion in the future. I'm not sure whether arrow would automatically write floats into double columns, for example. I would also like to make sure we have better error messages, not just "ValueError: Table schema does not match schema used to create file: ...". Those will be good follow ups.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think this ties into the work that @syun64 is doing where we have to make sure that we map the fields correctly, and then I think we can add options to massage the Arrow schema into the Iceberg one (which should be leading).

We can create a visitorWithPartner that will see if the promotions are possible. One that comes to my mind directly, is checking if there are any nulls. Arrow marks the schemas as nullable by default, while there are no nulls.

@rdblue

Copy link
Copy Markdown
Contributor

@Fokko, this works great and I don't see any blockers so I've approved it.

I think there are a few things to consider in terms of how we want to do this moving forward (whether to use separate manifests for example) but we can get this in and iterate from there. It also looks like this is pretty close to being able to run the overwrite filter, too! Great work.

@Fokko
Fokko merged commit 8f7927b into apache:mainJan 18, 2024
@Fokko
Fokko deleted the fd-write branch January 18, 2024 10:19
@Fokko

Copy link
Copy Markdown
ContributorAuthor

Many thanks again for the great review @rdblue. I went forward and merged it 🙌 Probably we'll improve a bit more on the code-style structure when we add #270 We went a bit back and forth a couple of times. Great having this in 🚀

}

TABLE_SCHEMA = Schema(
NestedField(field_id=1, name="bool", field_type=BooleanType(), required=False),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Fokko I have tested the write I see if we make Table schema for any field required =True example NestedField(field_id=13, name="fixed", field_type=FixedType(16), required=True)

ValueError: Table schema does not match schema used to create file:

I always fails 1102 if not table.schema.equals(self.schema, check_metadata=False):
1103 msg = ('Table schema does not match schema used to create file: '
1104 '\ntable:\n{!s} vs. \nfile:\n{!s}'
1105 .format(table.schema, self.schema))
-> 1106 raise ValueError(msg)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PyArrow fields by default are nullable, which matches all the nested fields in TABLE_SCHEMA. If you want to test against non-nullable fields, then arrow_table_with_null or whatever other pyarrow table you are instantiating should have nullable=False for the field that has required=True.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sebpretzer thanks for the clarification. Tested it work as expected

@mkleinbort-ic

Copy link
Copy Markdown

Is there an ETA for write functionality in the released version?

@EternalDeiwos

Copy link
Copy Markdown
Contributor

Check the attached milestone for progress. When those issues are resolved it will be ready for release.

@sungwy

Copy link
Copy Markdown
Collaborator

Hi @mkleinbort-ic we've just started voting on the first release candidate that incorporates this change

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Uploading Data to Iceberg Python write support

11 participants

@Fokko@samplec0de@rdblue@mkleinbort-ic@EternalDeiwos@sungwy@robtandy@asheeshgarg@sebpretzer@HonahX@jqin61
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Write support - #41

Merged
Fokko merged 71 commits into
apache:mainfrom
Fokko:fd-write
Jan 18, 2024
Merged

Write support#41
Fokko merged 71 commits into
apache:mainfrom
Fokko:fd-write

Conversation

@Fokko

@FokkoFokko commented Oct 4, 2023

Copy link
Copy Markdown
Contributor

Experimental branch to implement writing. Much of the changes here will be split out into small manageable PRs.

Resolves#181
Resolves#23

For V1 and V2 there are some differences that are hard
to enforce without this:
- `1: snapshot_id` is required for V1, optional for V2
- `105: block_size_in_bytes` needs to be written for V1, but omitted for V2 (this leverages the `write-default`).
- `3: sequence_number` and `4: file_sequence_number` can be omited for V1.
Everything that we read, we map it to V2. However, when writing
we also want to be compliant with the V1 spec, and this is where
the writer tree comes in since we construct a tree for V1 or V2.
@FokkoFokko added this to the PyIceberg 0.6.0 release milestone Oct 9, 2023
@samplec0de

Copy link
Copy Markdown

Very relevant! I'm looking forward to it, thank you!

@FokkoFokko mentioned this pull request Oct 11, 2023
4 tasks
Comment threadmkdocs/docs/api.md

When reading the table `tbl.scan().to_arrow()` you can see that `Groningen` is now also part of the table:

```

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While working on this, I also checked the field-ids:

parq 00000-0-27345354-67b8-4861-95ca-c2de9dc8d3fe.parquet --schema
# Schema <pyarrow._parquet.ParquetSchema object at 0x11eca2e00>
required group field_id=-1 schema {
optional binary field_id=1 city (String);
optional double field_id=2 lat;
optional double field_id=3 long;
}

Comment threadmkdocs/docs/api.md
schema = Schema(
NestedField(1, "city", StringType(), required=False),
NestedField(2, "lat", DoubleType(), required=False),
NestedField(3, "long", DoubleType(), required=False),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't required=False the default?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, the default is the more strict True. I've set it to False because PyArrow produces nullable fields by default

Comment threadpyiceberg/table/__init__.py Outdated
if len(self.sort_order().fields) > 0:
raise ValueError("Cannot write to tables with a sort-order")

snapshot_id = self.new_snapshot_id()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this can be handled inside of _MergeAppend since it has the table.

Comment threadpyiceberg/table/__init__.py Outdated
snapshot_id = self.new_snapshot_id()

data_files = _dataframe_to_data_files(self, df=df)
merge = _MergeAppend(operation=Operation.APPEND, table=self, snapshot_id=snapshot_id)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this really a "merge append" if the operation may be overwrite? You might consider using _MergingCommit or _MergingSnapshotProducer (if you want to follow the Java convention).

Comment threadpyiceberg/table/__init__.py
for entry in manifest.fetch_manifest_entry(self._table.io, discard_deleted=True)
]

list_of_entries = executor.map(_get_entries, previous_snapshot.manifests(self._table.io))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may be a good idea to defensively use only data manifests here instead of all manifests.

status=ManifestEntryStatus.DELETED,
snapshot_id=entry.snapshot_id,
data_sequence_number=entry.data_sequence_number,
file_sequence_number=entry.file_sequence_number,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good.

Comment threadpyiceberg/table/__init__.py Outdated
raise ValueError(f"Not implemented for: {self._operation}")

def _manifests(self) -> List[ManifestFile]:
manifests = []

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Since this is empty, it looks like this is just the newly created manifests. It may be a good idea to name this new_manifests.

Comment threadpyiceberg/table/__init__.py Outdated
summary=Summary(operation=self._operation, **self._summary()),
previous_summary=previous_snapshot.summary if previous_snapshot is not None else None,
truncate_full_table=self._operation == Operation.OVERWRITE,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this block could be moved to _summary so that it produces the correct summary without the need to modify it afterward. That seems a bit cleaner to me, rather than having a two-step process split across methods.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's a great point 👍

if self._operation == Operation.APPEND and previous_snapshot is not None:
# In case we want to append, just add the existing manifests
writer.add_manifests(previous_snapshot.manifests(io=self._table.io))
writer.add_manifests(new_manifests)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the note above, I think it would be cleaner to have _manifests produce the complete set of manifests, not just the replacement ones. That method already relies on _deleted_entries to produce deletes, so it may as well also be responsible for checking whether to include the existing manifests.

Another option is to make _manifests produce just manifests for the appended files and handle deletes separately, but it looks like your approach here is to create just one manifest with both deletes and appends.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great suggestion. I've moved all the logic to _manifests()

Comment threadpyiceberg/table/__init__.py Outdated
)

for delete_entry in deleted_entries:
writer.add_entry(delete_entry)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this approach works fine, but I want to point out that there are drawbacks to writing the deletes in the same manifest:

  1. A reader has to load all of the deletes, even though the files aren't useful. If they are in a separate manifest, readers can filter out manifests that have no EXISTING or ADDED data files.
  2. Manifests with no data files can be removed in future append commits.
  3. This write is single-threaded. In the Java implementation, we produce a manifest of deleted data files for each existing manifest. That allows us to parallelize the operation.

Here's the logic we use to drop manifests that aren't needed on the Java side when producing the new list of manifests:

// only keep manifests that have live data files or that were written by this commitPredicate<ManifestFile> shouldKeep =
manifest ->
manifest.hasAddedFiles()
|| manifest.hasExistingFiles()
|| manifest.snapshotId() == snapshotId();

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I missed this one, thanks for suggesting it! 👍 I've split out the ADDED, EXISTING, and DELETE entries into separate manifests that write in parallel.

Comment threadmkdocs/docs/api.md

## Write support

With PyIceberg 0.6.0 write support is added through Arrow. Let's consider an Arrow Table:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this example! Made it really easy to test out.

The example works great cut & pasted into a REPL. I also tested modifications to the dataframe schema passed to append and it does the right thing. I get a schema error for a few cases:

  • Missing column long
  • Type mismatch string instead of double
  • Extra column country

Looks like Arrow requires that the schema matches, which is great.

It would be nice to allow some type promotion in the future. I'm not sure whether arrow would automatically write floats into double columns, for example. I would also like to make sure we have better error messages, not just "ValueError: Table schema does not match schema used to create file: ...". Those will be good follow ups.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think this ties into the work that @syun64 is doing where we have to make sure that we map the fields correctly, and then I think we can add options to massage the Arrow schema into the Iceberg one (which should be leading).

We can create a visitorWithPartner that will see if the promotions are possible. One that comes to my mind directly, is checking if there are any nulls. Arrow marks the schemas as nullable by default, while there are no nulls.

@rdblue

Copy link
Copy Markdown
Contributor

@Fokko, this works great and I don't see any blockers so I've approved it.

I think there are a few things to consider in terms of how we want to do this moving forward (whether to use separate manifests for example) but we can get this in and iterate from there. It also looks like this is pretty close to being able to run the overwrite filter, too! Great work.

@Fokko
Fokko merged commit 8f7927b into apache:mainJan 18, 2024
@Fokko
Fokko deleted the fd-write branch January 18, 2024 10:19
@Fokko

Copy link
Copy Markdown
ContributorAuthor

Many thanks again for the great review @rdblue. I went forward and merged it 🙌 Probably we'll improve a bit more on the code-style structure when we add #270 We went a bit back and forth a couple of times. Great having this in 🚀

}

TABLE_SCHEMA = Schema(
NestedField(field_id=1, name="bool", field_type=BooleanType(), required=False),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Fokko I have tested the write I see if we make Table schema for any field required =True example NestedField(field_id=13, name="fixed", field_type=FixedType(16), required=True)

ValueError: Table schema does not match schema used to create file:

I always fails 1102 if not table.schema.equals(self.schema, check_metadata=False):
1103 msg = ('Table schema does not match schema used to create file: '
1104 '\ntable:\n{!s} vs. \nfile:\n{!s}'
1105 .format(table.schema, self.schema))
-> 1106 raise ValueError(msg)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PyArrow fields by default are nullable, which matches all the nested fields in TABLE_SCHEMA. If you want to test against non-nullable fields, then arrow_table_with_null or whatever other pyarrow table you are instantiating should have nullable=False for the field that has required=True.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sebpretzer thanks for the clarification. Tested it work as expected

@mkleinbort-ic

Copy link
Copy Markdown

Is there an ETA for write functionality in the released version?

@EternalDeiwos

Copy link
Copy Markdown
Contributor

Check the attached milestone for progress. When those issues are resolved it will be ready for release.

@sungwy

Copy link
Copy Markdown
Collaborator

Hi @mkleinbort-ic we've just started voting on the first release candidate that incorporates this change

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Uploading Data to Iceberg Python write support

11 participants

@Fokko@samplec0de@rdblue@mkleinbort-ic@EternalDeiwos@sungwy@robtandy@asheeshgarg@sebpretzer@HonahX@jqin61
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Write support - #41

Merged
Fokko merged 71 commits into
apache:mainfrom
Fokko:fd-write
Jan 18, 2024
Merged

Write support#41
Fokko merged 71 commits into
apache:mainfrom
Fokko:fd-write

Conversation

@Fokko

@FokkoFokko commented Oct 4, 2023

Copy link
Copy Markdown
Contributor

Experimental branch to implement writing. Much of the changes here will be split out into small manageable PRs.

Resolves#181
Resolves#23

For V1 and V2 there are some differences that are hard
to enforce without this:
- `1: snapshot_id` is required for V1, optional for V2
- `105: block_size_in_bytes` needs to be written for V1, but omitted for V2 (this leverages the `write-default`).
- `3: sequence_number` and `4: file_sequence_number` can be omited for V1.
Everything that we read, we map it to V2. However, when writing
we also want to be compliant with the V1 spec, and this is where
the writer tree comes in since we construct a tree for V1 or V2.
@FokkoFokko added this to the PyIceberg 0.6.0 release milestone Oct 9, 2023
@samplec0de

Copy link
Copy Markdown

Very relevant! I'm looking forward to it, thank you!

@FokkoFokko mentioned this pull request Oct 11, 2023
4 tasks
Comment threadmkdocs/docs/api.md

When reading the table `tbl.scan().to_arrow()` you can see that `Groningen` is now also part of the table:

```

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While working on this, I also checked the field-ids:

parq 00000-0-27345354-67b8-4861-95ca-c2de9dc8d3fe.parquet --schema
# Schema <pyarrow._parquet.ParquetSchema object at 0x11eca2e00>
required group field_id=-1 schema {
optional binary field_id=1 city (String);
optional double field_id=2 lat;
optional double field_id=3 long;
}

Comment threadmkdocs/docs/api.md
schema = Schema(
NestedField(1, "city", StringType(), required=False),
NestedField(2, "lat", DoubleType(), required=False),
NestedField(3, "long", DoubleType(), required=False),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't required=False the default?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, the default is the more strict True. I've set it to False because PyArrow produces nullable fields by default

Comment threadpyiceberg/table/__init__.py Outdated
if len(self.sort_order().fields) > 0:
raise ValueError("Cannot write to tables with a sort-order")

snapshot_id = self.new_snapshot_id()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this can be handled inside of _MergeAppend since it has the table.

Comment threadpyiceberg/table/__init__.py Outdated
snapshot_id = self.new_snapshot_id()

data_files = _dataframe_to_data_files(self, df=df)
merge = _MergeAppend(operation=Operation.APPEND, table=self, snapshot_id=snapshot_id)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this really a "merge append" if the operation may be overwrite? You might consider using _MergingCommit or _MergingSnapshotProducer (if you want to follow the Java convention).

Comment threadpyiceberg/table/__init__.py
for entry in manifest.fetch_manifest_entry(self._table.io, discard_deleted=True)
]

list_of_entries = executor.map(_get_entries, previous_snapshot.manifests(self._table.io))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may be a good idea to defensively use only data manifests here instead of all manifests.

status=ManifestEntryStatus.DELETED,
snapshot_id=entry.snapshot_id,
data_sequence_number=entry.data_sequence_number,
file_sequence_number=entry.file_sequence_number,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good.

Comment threadpyiceberg/table/__init__.py Outdated
raise ValueError(f"Not implemented for: {self._operation}")

def _manifests(self) -> List[ManifestFile]:
manifests = []

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Since this is empty, it looks like this is just the newly created manifests. It may be a good idea to name this new_manifests.

Comment threadpyiceberg/table/__init__.py Outdated
summary=Summary(operation=self._operation, **self._summary()),
previous_summary=previous_snapshot.summary if previous_snapshot is not None else None,
truncate_full_table=self._operation == Operation.OVERWRITE,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this block could be moved to _summary so that it produces the correct summary without the need to modify it afterward. That seems a bit cleaner to me, rather than having a two-step process split across methods.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's a great point 👍

if self._operation == Operation.APPEND and previous_snapshot is not None:
# In case we want to append, just add the existing manifests
writer.add_manifests(previous_snapshot.manifests(io=self._table.io))
writer.add_manifests(new_manifests)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the note above, I think it would be cleaner to have _manifests produce the complete set of manifests, not just the replacement ones. That method already relies on _deleted_entries to produce deletes, so it may as well also be responsible for checking whether to include the existing manifests.

Another option is to make _manifests produce just manifests for the appended files and handle deletes separately, but it looks like your approach here is to create just one manifest with both deletes and appends.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great suggestion. I've moved all the logic to _manifests()

Comment threadpyiceberg/table/__init__.py Outdated
)

for delete_entry in deleted_entries:
writer.add_entry(delete_entry)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this approach works fine, but I want to point out that there are drawbacks to writing the deletes in the same manifest:

  1. A reader has to load all of the deletes, even though the files aren't useful. If they are in a separate manifest, readers can filter out manifests that have no EXISTING or ADDED data files.
  2. Manifests with no data files can be removed in future append commits.
  3. This write is single-threaded. In the Java implementation, we produce a manifest of deleted data files for each existing manifest. That allows us to parallelize the operation.

Here's the logic we use to drop manifests that aren't needed on the Java side when producing the new list of manifests:

// only keep manifests that have live data files or that were written by this commitPredicate<ManifestFile> shouldKeep =
manifest ->
manifest.hasAddedFiles()
|| manifest.hasExistingFiles()
|| manifest.snapshotId() == snapshotId();

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I missed this one, thanks for suggesting it! 👍 I've split out the ADDED, EXISTING, and DELETE entries into separate manifests that write in parallel.

Comment threadmkdocs/docs/api.md

## Write support

With PyIceberg 0.6.0 write support is added through Arrow. Let's consider an Arrow Table:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this example! Made it really easy to test out.

The example works great cut & pasted into a REPL. I also tested modifications to the dataframe schema passed to append and it does the right thing. I get a schema error for a few cases:

  • Missing column long
  • Type mismatch string instead of double
  • Extra column country

Looks like Arrow requires that the schema matches, which is great.

It would be nice to allow some type promotion in the future. I'm not sure whether arrow would automatically write floats into double columns, for example. I would also like to make sure we have better error messages, not just "ValueError: Table schema does not match schema used to create file: ...". Those will be good follow ups.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think this ties into the work that @syun64 is doing where we have to make sure that we map the fields correctly, and then I think we can add options to massage the Arrow schema into the Iceberg one (which should be leading).

We can create a visitorWithPartner that will see if the promotions are possible. One that comes to my mind directly, is checking if there are any nulls. Arrow marks the schemas as nullable by default, while there are no nulls.

@rdblue

Copy link
Copy Markdown
Contributor

@Fokko, this works great and I don't see any blockers so I've approved it.

I think there are a few things to consider in terms of how we want to do this moving forward (whether to use separate manifests for example) but we can get this in and iterate from there. It also looks like this is pretty close to being able to run the overwrite filter, too! Great work.

@Fokko
Fokko merged commit 8f7927b into apache:mainJan 18, 2024
@Fokko
Fokko deleted the fd-write branch January 18, 2024 10:19
@Fokko

Copy link
Copy Markdown
ContributorAuthor

Many thanks again for the great review @rdblue. I went forward and merged it 🙌 Probably we'll improve a bit more on the code-style structure when we add #270 We went a bit back and forth a couple of times. Great having this in 🚀

}

TABLE_SCHEMA = Schema(
NestedField(field_id=1, name="bool", field_type=BooleanType(), required=False),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Fokko I have tested the write I see if we make Table schema for any field required =True example NestedField(field_id=13, name="fixed", field_type=FixedType(16), required=True)

ValueError: Table schema does not match schema used to create file:

I always fails 1102 if not table.schema.equals(self.schema, check_metadata=False):
1103 msg = ('Table schema does not match schema used to create file: '
1104 '\ntable:\n{!s} vs. \nfile:\n{!s}'
1105 .format(table.schema, self.schema))
-> 1106 raise ValueError(msg)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PyArrow fields by default are nullable, which matches all the nested fields in TABLE_SCHEMA. If you want to test against non-nullable fields, then arrow_table_with_null or whatever other pyarrow table you are instantiating should have nullable=False for the field that has required=True.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sebpretzer thanks for the clarification. Tested it work as expected

@mkleinbort-ic

Copy link
Copy Markdown

Is there an ETA for write functionality in the released version?

@EternalDeiwos

Copy link
Copy Markdown
Contributor

Check the attached milestone for progress. When those issues are resolved it will be ready for release.

@sungwy

Copy link
Copy Markdown
Collaborator

Hi @mkleinbort-ic we've just started voting on the first release candidate that incorporates this change

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Uploading Data to Iceberg Python write support

11 participants

@Fokko@samplec0de@rdblue@mkleinbort-ic@EternalDeiwos@sungwy@robtandy@asheeshgarg@sebpretzer@HonahX@jqin61
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Write support - #41

Merged
Fokko merged 71 commits into
apache:mainfrom
Fokko:fd-write
Jan 18, 2024
Merged

Write support#41
Fokko merged 71 commits into
apache:mainfrom
Fokko:fd-write

Conversation

@Fokko

@FokkoFokko commented Oct 4, 2023

Copy link
Copy Markdown
Contributor

Experimental branch to implement writing. Much of the changes here will be split out into small manageable PRs.

Resolves#181
Resolves#23

For V1 and V2 there are some differences that are hard
to enforce without this:
- `1: snapshot_id` is required for V1, optional for V2
- `105: block_size_in_bytes` needs to be written for V1, but omitted for V2 (this leverages the `write-default`).
- `3: sequence_number` and `4: file_sequence_number` can be omited for V1.
Everything that we read, we map it to V2. However, when writing
we also want to be compliant with the V1 spec, and this is where
the writer tree comes in since we construct a tree for V1 or V2.
@FokkoFokko added this to the PyIceberg 0.6.0 release milestone Oct 9, 2023
@samplec0de

Copy link
Copy Markdown

Very relevant! I'm looking forward to it, thank you!

@FokkoFokko mentioned this pull request Oct 11, 2023
4 tasks
Comment threadmkdocs/docs/api.md

When reading the table `tbl.scan().to_arrow()` you can see that `Groningen` is now also part of the table:

```

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While working on this, I also checked the field-ids:

parq 00000-0-27345354-67b8-4861-95ca-c2de9dc8d3fe.parquet --schema
# Schema <pyarrow._parquet.ParquetSchema object at 0x11eca2e00>
required group field_id=-1 schema {
optional binary field_id=1 city (String);
optional double field_id=2 lat;
optional double field_id=3 long;
}

Comment threadmkdocs/docs/api.md
schema = Schema(
NestedField(1, "city", StringType(), required=False),
NestedField(2, "lat", DoubleType(), required=False),
NestedField(3, "long", DoubleType(), required=False),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't required=False the default?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, the default is the more strict True. I've set it to False because PyArrow produces nullable fields by default

Comment threadpyiceberg/table/__init__.py Outdated
if len(self.sort_order().fields) > 0:
raise ValueError("Cannot write to tables with a sort-order")

snapshot_id = self.new_snapshot_id()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this can be handled inside of _MergeAppend since it has the table.

Comment threadpyiceberg/table/__init__.py Outdated
snapshot_id = self.new_snapshot_id()

data_files = _dataframe_to_data_files(self, df=df)
merge = _MergeAppend(operation=Operation.APPEND, table=self, snapshot_id=snapshot_id)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this really a "merge append" if the operation may be overwrite? You might consider using _MergingCommit or _MergingSnapshotProducer (if you want to follow the Java convention).

Comment threadpyiceberg/table/__init__.py
for entry in manifest.fetch_manifest_entry(self._table.io, discard_deleted=True)
]

list_of_entries = executor.map(_get_entries, previous_snapshot.manifests(self._table.io))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may be a good idea to defensively use only data manifests here instead of all manifests.

status=ManifestEntryStatus.DELETED,
snapshot_id=entry.snapshot_id,
data_sequence_number=entry.data_sequence_number,
file_sequence_number=entry.file_sequence_number,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good.

Comment threadpyiceberg/table/__init__.py Outdated
raise ValueError(f"Not implemented for: {self._operation}")

def _manifests(self) -> List[ManifestFile]:
manifests = []

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Since this is empty, it looks like this is just the newly created manifests. It may be a good idea to name this new_manifests.

Comment threadpyiceberg/table/__init__.py Outdated
summary=Summary(operation=self._operation, **self._summary()),
previous_summary=previous_snapshot.summary if previous_snapshot is not None else None,
truncate_full_table=self._operation == Operation.OVERWRITE,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this block could be moved to _summary so that it produces the correct summary without the need to modify it afterward. That seems a bit cleaner to me, rather than having a two-step process split across methods.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's a great point 👍

if self._operation == Operation.APPEND and previous_snapshot is not None:
# In case we want to append, just add the existing manifests
writer.add_manifests(previous_snapshot.manifests(io=self._table.io))
writer.add_manifests(new_manifests)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the note above, I think it would be cleaner to have _manifests produce the complete set of manifests, not just the replacement ones. That method already relies on _deleted_entries to produce deletes, so it may as well also be responsible for checking whether to include the existing manifests.

Another option is to make _manifests produce just manifests for the appended files and handle deletes separately, but it looks like your approach here is to create just one manifest with both deletes and appends.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great suggestion. I've moved all the logic to _manifests()

Comment threadpyiceberg/table/__init__.py Outdated
)

for delete_entry in deleted_entries:
writer.add_entry(delete_entry)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this approach works fine, but I want to point out that there are drawbacks to writing the deletes in the same manifest:

  1. A reader has to load all of the deletes, even though the files aren't useful. If they are in a separate manifest, readers can filter out manifests that have no EXISTING or ADDED data files.
  2. Manifests with no data files can be removed in future append commits.
  3. This write is single-threaded. In the Java implementation, we produce a manifest of deleted data files for each existing manifest. That allows us to parallelize the operation.

Here's the logic we use to drop manifests that aren't needed on the Java side when producing the new list of manifests:

// only keep manifests that have live data files or that were written by this commitPredicate<ManifestFile> shouldKeep =
manifest ->
manifest.hasAddedFiles()
|| manifest.hasExistingFiles()
|| manifest.snapshotId() == snapshotId();

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I missed this one, thanks for suggesting it! 👍 I've split out the ADDED, EXISTING, and DELETE entries into separate manifests that write in parallel.

Comment threadmkdocs/docs/api.md

## Write support

With PyIceberg 0.6.0 write support is added through Arrow. Let's consider an Arrow Table:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this example! Made it really easy to test out.

The example works great cut & pasted into a REPL. I also tested modifications to the dataframe schema passed to append and it does the right thing. I get a schema error for a few cases:

  • Missing column long
  • Type mismatch string instead of double
  • Extra column country

Looks like Arrow requires that the schema matches, which is great.

It would be nice to allow some type promotion in the future. I'm not sure whether arrow would automatically write floats into double columns, for example. I would also like to make sure we have better error messages, not just "ValueError: Table schema does not match schema used to create file: ...". Those will be good follow ups.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think this ties into the work that @syun64 is doing where we have to make sure that we map the fields correctly, and then I think we can add options to massage the Arrow schema into the Iceberg one (which should be leading).

We can create a visitorWithPartner that will see if the promotions are possible. One that comes to my mind directly, is checking if there are any nulls. Arrow marks the schemas as nullable by default, while there are no nulls.

@rdblue

Copy link
Copy Markdown
Contributor

@Fokko, this works great and I don't see any blockers so I've approved it.

I think there are a few things to consider in terms of how we want to do this moving forward (whether to use separate manifests for example) but we can get this in and iterate from there. It also looks like this is pretty close to being able to run the overwrite filter, too! Great work.

@Fokko
Fokko merged commit 8f7927b into apache:mainJan 18, 2024
@Fokko
Fokko deleted the fd-write branch January 18, 2024 10:19
@Fokko

Copy link
Copy Markdown
ContributorAuthor

Many thanks again for the great review @rdblue. I went forward and merged it 🙌 Probably we'll improve a bit more on the code-style structure when we add #270 We went a bit back and forth a couple of times. Great having this in 🚀

}

TABLE_SCHEMA = Schema(
NestedField(field_id=1, name="bool", field_type=BooleanType(), required=False),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Fokko I have tested the write I see if we make Table schema for any field required =True example NestedField(field_id=13, name="fixed", field_type=FixedType(16), required=True)

ValueError: Table schema does not match schema used to create file:

I always fails 1102 if not table.schema.equals(self.schema, check_metadata=False):
1103 msg = ('Table schema does not match schema used to create file: '
1104 '\ntable:\n{!s} vs. \nfile:\n{!s}'
1105 .format(table.schema, self.schema))
-> 1106 raise ValueError(msg)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PyArrow fields by default are nullable, which matches all the nested fields in TABLE_SCHEMA. If you want to test against non-nullable fields, then arrow_table_with_null or whatever other pyarrow table you are instantiating should have nullable=False for the field that has required=True.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sebpretzer thanks for the clarification. Tested it work as expected

@mkleinbort-ic

Copy link
Copy Markdown

Is there an ETA for write functionality in the released version?

@EternalDeiwos

Copy link
Copy Markdown
Contributor

Check the attached milestone for progress. When those issues are resolved it will be ready for release.

@sungwy

Copy link
Copy Markdown
Collaborator

Hi @mkleinbort-ic we've just started voting on the first release candidate that incorporates this change

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Uploading Data to Iceberg Python write support

11 participants

@Fokko@samplec0de@rdblue@mkleinbort-ic@EternalDeiwos@sungwy@robtandy@asheeshgarg@sebpretzer@HonahX@jqin61
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Write support - #41

Merged
Fokko merged 71 commits into
apache:mainfrom
Fokko:fd-write
Jan 18, 2024
Merged

Write support#41
Fokko merged 71 commits into
apache:mainfrom
Fokko:fd-write

Conversation

@Fokko

@FokkoFokko commented Oct 4, 2023

Copy link
Copy Markdown
Contributor

Experimental branch to implement writing. Much of the changes here will be split out into small manageable PRs.

Resolves#181
Resolves#23

For V1 and V2 there are some differences that are hard
to enforce without this:
- `1: snapshot_id` is required for V1, optional for V2
- `105: block_size_in_bytes` needs to be written for V1, but omitted for V2 (this leverages the `write-default`).
- `3: sequence_number` and `4: file_sequence_number` can be omited for V1.
Everything that we read, we map it to V2. However, when writing
we also want to be compliant with the V1 spec, and this is where
the writer tree comes in since we construct a tree for V1 or V2.
@FokkoFokko added this to the PyIceberg 0.6.0 release milestone Oct 9, 2023
@samplec0de

Copy link
Copy Markdown

Very relevant! I'm looking forward to it, thank you!

@FokkoFokko mentioned this pull request Oct 11, 2023
4 tasks
Comment threadmkdocs/docs/api.md

When reading the table `tbl.scan().to_arrow()` you can see that `Groningen` is now also part of the table:

```

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While working on this, I also checked the field-ids:

parq 00000-0-27345354-67b8-4861-95ca-c2de9dc8d3fe.parquet --schema
# Schema <pyarrow._parquet.ParquetSchema object at 0x11eca2e00>
required group field_id=-1 schema {
optional binary field_id=1 city (String);
optional double field_id=2 lat;
optional double field_id=3 long;
}

Comment threadmkdocs/docs/api.md
schema = Schema(
NestedField(1, "city", StringType(), required=False),
NestedField(2, "lat", DoubleType(), required=False),
NestedField(3, "long", DoubleType(), required=False),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't required=False the default?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, the default is the more strict True. I've set it to False because PyArrow produces nullable fields by default

Comment threadpyiceberg/table/__init__.py Outdated
if len(self.sort_order().fields) > 0:
raise ValueError("Cannot write to tables with a sort-order")

snapshot_id = self.new_snapshot_id()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this can be handled inside of _MergeAppend since it has the table.

Comment threadpyiceberg/table/__init__.py Outdated
snapshot_id = self.new_snapshot_id()

data_files = _dataframe_to_data_files(self, df=df)
merge = _MergeAppend(operation=Operation.APPEND, table=self, snapshot_id=snapshot_id)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this really a "merge append" if the operation may be overwrite? You might consider using _MergingCommit or _MergingSnapshotProducer (if you want to follow the Java convention).

Comment threadpyiceberg/table/__init__.py
for entry in manifest.fetch_manifest_entry(self._table.io, discard_deleted=True)
]

list_of_entries = executor.map(_get_entries, previous_snapshot.manifests(self._table.io))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may be a good idea to defensively use only data manifests here instead of all manifests.

status=ManifestEntryStatus.DELETED,
snapshot_id=entry.snapshot_id,
data_sequence_number=entry.data_sequence_number,
file_sequence_number=entry.file_sequence_number,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good.

Comment threadpyiceberg/table/__init__.py Outdated
raise ValueError(f"Not implemented for: {self._operation}")

def _manifests(self) -> List[ManifestFile]:
manifests = []

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Since this is empty, it looks like this is just the newly created manifests. It may be a good idea to name this new_manifests.

Comment threadpyiceberg/table/__init__.py Outdated
summary=Summary(operation=self._operation, **self._summary()),
previous_summary=previous_snapshot.summary if previous_snapshot is not None else None,
truncate_full_table=self._operation == Operation.OVERWRITE,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this block could be moved to _summary so that it produces the correct summary without the need to modify it afterward. That seems a bit cleaner to me, rather than having a two-step process split across methods.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's a great point 👍

if self._operation == Operation.APPEND and previous_snapshot is not None:
# In case we want to append, just add the existing manifests
writer.add_manifests(previous_snapshot.manifests(io=self._table.io))
writer.add_manifests(new_manifests)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the note above, I think it would be cleaner to have _manifests produce the complete set of manifests, not just the replacement ones. That method already relies on _deleted_entries to produce deletes, so it may as well also be responsible for checking whether to include the existing manifests.

Another option is to make _manifests produce just manifests for the appended files and handle deletes separately, but it looks like your approach here is to create just one manifest with both deletes and appends.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great suggestion. I've moved all the logic to _manifests()

Comment threadpyiceberg/table/__init__.py Outdated
)

for delete_entry in deleted_entries:
writer.add_entry(delete_entry)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this approach works fine, but I want to point out that there are drawbacks to writing the deletes in the same manifest:

  1. A reader has to load all of the deletes, even though the files aren't useful. If they are in a separate manifest, readers can filter out manifests that have no EXISTING or ADDED data files.
  2. Manifests with no data files can be removed in future append commits.
  3. This write is single-threaded. In the Java implementation, we produce a manifest of deleted data files for each existing manifest. That allows us to parallelize the operation.

Here's the logic we use to drop manifests that aren't needed on the Java side when producing the new list of manifests:

// only keep manifests that have live data files or that were written by this commitPredicate<ManifestFile> shouldKeep =
manifest ->
manifest.hasAddedFiles()
|| manifest.hasExistingFiles()
|| manifest.snapshotId() == snapshotId();

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I missed this one, thanks for suggesting it! 👍 I've split out the ADDED, EXISTING, and DELETE entries into separate manifests that write in parallel.

Comment threadmkdocs/docs/api.md

## Write support

With PyIceberg 0.6.0 write support is added through Arrow. Let's consider an Arrow Table:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this example! Made it really easy to test out.

The example works great cut & pasted into a REPL. I also tested modifications to the dataframe schema passed to append and it does the right thing. I get a schema error for a few cases:

  • Missing column long
  • Type mismatch string instead of double
  • Extra column country

Looks like Arrow requires that the schema matches, which is great.

It would be nice to allow some type promotion in the future. I'm not sure whether arrow would automatically write floats into double columns, for example. I would also like to make sure we have better error messages, not just "ValueError: Table schema does not match schema used to create file: ...". Those will be good follow ups.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think this ties into the work that @syun64 is doing where we have to make sure that we map the fields correctly, and then I think we can add options to massage the Arrow schema into the Iceberg one (which should be leading).

We can create a visitorWithPartner that will see if the promotions are possible. One that comes to my mind directly, is checking if there are any nulls. Arrow marks the schemas as nullable by default, while there are no nulls.

@rdblue

Copy link
Copy Markdown
Contributor

@Fokko, this works great and I don't see any blockers so I've approved it.

I think there are a few things to consider in terms of how we want to do this moving forward (whether to use separate manifests for example) but we can get this in and iterate from there. It also looks like this is pretty close to being able to run the overwrite filter, too! Great work.

@Fokko
Fokko merged commit 8f7927b into apache:mainJan 18, 2024
@Fokko
Fokko deleted the fd-write branch January 18, 2024 10:19
@Fokko

Copy link
Copy Markdown
ContributorAuthor

Many thanks again for the great review @rdblue. I went forward and merged it 🙌 Probably we'll improve a bit more on the code-style structure when we add #270 We went a bit back and forth a couple of times. Great having this in 🚀

}

TABLE_SCHEMA = Schema(
NestedField(field_id=1, name="bool", field_type=BooleanType(), required=False),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Fokko I have tested the write I see if we make Table schema for any field required =True example NestedField(field_id=13, name="fixed", field_type=FixedType(16), required=True)

ValueError: Table schema does not match schema used to create file:

I always fails 1102 if not table.schema.equals(self.schema, check_metadata=False):
1103 msg = ('Table schema does not match schema used to create file: '
1104 '\ntable:\n{!s} vs. \nfile:\n{!s}'
1105 .format(table.schema, self.schema))
-> 1106 raise ValueError(msg)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PyArrow fields by default are nullable, which matches all the nested fields in TABLE_SCHEMA. If you want to test against non-nullable fields, then arrow_table_with_null or whatever other pyarrow table you are instantiating should have nullable=False for the field that has required=True.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sebpretzer thanks for the clarification. Tested it work as expected

@mkleinbort-ic

Copy link
Copy Markdown

Is there an ETA for write functionality in the released version?

@EternalDeiwos

Copy link
Copy Markdown
Contributor

Check the attached milestone for progress. When those issues are resolved it will be ready for release.

@sungwy

Copy link
Copy Markdown
Collaborator

Hi @mkleinbort-ic we've just started voting on the first release candidate that incorporates this change

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Uploading Data to Iceberg Python write support

11 participants

@Fokko@samplec0de@rdblue@mkleinbort-ic@EternalDeiwos@sungwy@robtandy@asheeshgarg@sebpretzer@HonahX@jqin61
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Write support - #41

Merged
Fokko merged 71 commits into
apache:mainfrom
Fokko:fd-write
Jan 18, 2024
Merged

Write support#41
Fokko merged 71 commits into
apache:mainfrom
Fokko:fd-write

Conversation

@Fokko

@FokkoFokko commented Oct 4, 2023

Copy link
Copy Markdown
Contributor

Experimental branch to implement writing. Much of the changes here will be split out into small manageable PRs.

Resolves#181
Resolves#23

For V1 and V2 there are some differences that are hard
to enforce without this:
- `1: snapshot_id` is required for V1, optional for V2
- `105: block_size_in_bytes` needs to be written for V1, but omitted for V2 (this leverages the `write-default`).
- `3: sequence_number` and `4: file_sequence_number` can be omited for V1.
Everything that we read, we map it to V2. However, when writing
we also want to be compliant with the V1 spec, and this is where
the writer tree comes in since we construct a tree for V1 or V2.
@FokkoFokko added this to the PyIceberg 0.6.0 release milestone Oct 9, 2023
@samplec0de

Copy link
Copy Markdown

Very relevant! I'm looking forward to it, thank you!

@FokkoFokko mentioned this pull request Oct 11, 2023
4 tasks
Comment threadmkdocs/docs/api.md

When reading the table `tbl.scan().to_arrow()` you can see that `Groningen` is now also part of the table:

```

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While working on this, I also checked the field-ids:

parq 00000-0-27345354-67b8-4861-95ca-c2de9dc8d3fe.parquet --schema
# Schema <pyarrow._parquet.ParquetSchema object at 0x11eca2e00>
required group field_id=-1 schema {
optional binary field_id=1 city (String);
optional double field_id=2 lat;
optional double field_id=3 long;
}

Comment threadmkdocs/docs/api.md
schema = Schema(
NestedField(1, "city", StringType(), required=False),
NestedField(2, "lat", DoubleType(), required=False),
NestedField(3, "long", DoubleType(), required=False),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't required=False the default?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, the default is the more strict True. I've set it to False because PyArrow produces nullable fields by default

Comment threadpyiceberg/table/__init__.py Outdated
if len(self.sort_order().fields) > 0:
raise ValueError("Cannot write to tables with a sort-order")

snapshot_id = self.new_snapshot_id()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this can be handled inside of _MergeAppend since it has the table.

Comment threadpyiceberg/table/__init__.py Outdated
snapshot_id = self.new_snapshot_id()

data_files = _dataframe_to_data_files(self, df=df)
merge = _MergeAppend(operation=Operation.APPEND, table=self, snapshot_id=snapshot_id)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this really a "merge append" if the operation may be overwrite? You might consider using _MergingCommit or _MergingSnapshotProducer (if you want to follow the Java convention).

Comment threadpyiceberg/table/__init__.py
for entry in manifest.fetch_manifest_entry(self._table.io, discard_deleted=True)
]

list_of_entries = executor.map(_get_entries, previous_snapshot.manifests(self._table.io))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may be a good idea to defensively use only data manifests here instead of all manifests.

status=ManifestEntryStatus.DELETED,
snapshot_id=entry.snapshot_id,
data_sequence_number=entry.data_sequence_number,
file_sequence_number=entry.file_sequence_number,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good.

Comment threadpyiceberg/table/__init__.py Outdated
raise ValueError(f"Not implemented for: {self._operation}")

def _manifests(self) -> List[ManifestFile]:
manifests = []

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Since this is empty, it looks like this is just the newly created manifests. It may be a good idea to name this new_manifests.

Comment threadpyiceberg/table/__init__.py Outdated
summary=Summary(operation=self._operation, **self._summary()),
previous_summary=previous_snapshot.summary if previous_snapshot is not None else None,
truncate_full_table=self._operation == Operation.OVERWRITE,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this block could be moved to _summary so that it produces the correct summary without the need to modify it afterward. That seems a bit cleaner to me, rather than having a two-step process split across methods.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's a great point 👍

if self._operation == Operation.APPEND and previous_snapshot is not None:
# In case we want to append, just add the existing manifests
writer.add_manifests(previous_snapshot.manifests(io=self._table.io))
writer.add_manifests(new_manifests)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the note above, I think it would be cleaner to have _manifests produce the complete set of manifests, not just the replacement ones. That method already relies on _deleted_entries to produce deletes, so it may as well also be responsible for checking whether to include the existing manifests.

Another option is to make _manifests produce just manifests for the appended files and handle deletes separately, but it looks like your approach here is to create just one manifest with both deletes and appends.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great suggestion. I've moved all the logic to _manifests()

Comment threadpyiceberg/table/__init__.py Outdated
)

for delete_entry in deleted_entries:
writer.add_entry(delete_entry)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this approach works fine, but I want to point out that there are drawbacks to writing the deletes in the same manifest:

  1. A reader has to load all of the deletes, even though the files aren't useful. If they are in a separate manifest, readers can filter out manifests that have no EXISTING or ADDED data files.
  2. Manifests with no data files can be removed in future append commits.
  3. This write is single-threaded. In the Java implementation, we produce a manifest of deleted data files for each existing manifest. That allows us to parallelize the operation.

Here's the logic we use to drop manifests that aren't needed on the Java side when producing the new list of manifests:

// only keep manifests that have live data files or that were written by this commitPredicate<ManifestFile> shouldKeep =
manifest ->
manifest.hasAddedFiles()
|| manifest.hasExistingFiles()
|| manifest.snapshotId() == snapshotId();

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I missed this one, thanks for suggesting it! 👍 I've split out the ADDED, EXISTING, and DELETE entries into separate manifests that write in parallel.

Comment threadmkdocs/docs/api.md

## Write support

With PyIceberg 0.6.0 write support is added through Arrow. Let's consider an Arrow Table:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this example! Made it really easy to test out.

The example works great cut & pasted into a REPL. I also tested modifications to the dataframe schema passed to append and it does the right thing. I get a schema error for a few cases:

  • Missing column long
  • Type mismatch string instead of double
  • Extra column country

Looks like Arrow requires that the schema matches, which is great.

It would be nice to allow some type promotion in the future. I'm not sure whether arrow would automatically write floats into double columns, for example. I would also like to make sure we have better error messages, not just "ValueError: Table schema does not match schema used to create file: ...". Those will be good follow ups.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think this ties into the work that @syun64 is doing where we have to make sure that we map the fields correctly, and then I think we can add options to massage the Arrow schema into the Iceberg one (which should be leading).

We can create a visitorWithPartner that will see if the promotions are possible. One that comes to my mind directly, is checking if there are any nulls. Arrow marks the schemas as nullable by default, while there are no nulls.

@rdblue

Copy link
Copy Markdown
Contributor

@Fokko, this works great and I don't see any blockers so I've approved it.

I think there are a few things to consider in terms of how we want to do this moving forward (whether to use separate manifests for example) but we can get this in and iterate from there. It also looks like this is pretty close to being able to run the overwrite filter, too! Great work.

@Fokko
Fokko merged commit 8f7927b into apache:mainJan 18, 2024
@Fokko
Fokko deleted the fd-write branch January 18, 2024 10:19
@Fokko

Copy link
Copy Markdown
ContributorAuthor

Many thanks again for the great review @rdblue. I went forward and merged it 🙌 Probably we'll improve a bit more on the code-style structure when we add #270 We went a bit back and forth a couple of times. Great having this in 🚀

}

TABLE_SCHEMA = Schema(
NestedField(field_id=1, name="bool", field_type=BooleanType(), required=False),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Fokko I have tested the write I see if we make Table schema for any field required =True example NestedField(field_id=13, name="fixed", field_type=FixedType(16), required=True)

ValueError: Table schema does not match schema used to create file:

I always fails 1102 if not table.schema.equals(self.schema, check_metadata=False):
1103 msg = ('Table schema does not match schema used to create file: '
1104 '\ntable:\n{!s} vs. \nfile:\n{!s}'
1105 .format(table.schema, self.schema))
-> 1106 raise ValueError(msg)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PyArrow fields by default are nullable, which matches all the nested fields in TABLE_SCHEMA. If you want to test against non-nullable fields, then arrow_table_with_null or whatever other pyarrow table you are instantiating should have nullable=False for the field that has required=True.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sebpretzer thanks for the clarification. Tested it work as expected

@mkleinbort-ic

Copy link
Copy Markdown

Is there an ETA for write functionality in the released version?

@EternalDeiwos

Copy link
Copy Markdown
Contributor

Check the attached milestone for progress. When those issues are resolved it will be ready for release.

@sungwy

Copy link
Copy Markdown
Collaborator

Hi @mkleinbort-ic we've just started voting on the first release candidate that incorporates this change

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Uploading Data to Iceberg Python write support

11 participants

@Fokko@samplec0de@rdblue@mkleinbort-ic@EternalDeiwos@sungwy@robtandy@asheeshgarg@sebpretzer@HonahX@jqin61