laskoviymishka commented on code in PR #2005:
URL: https://github.com/apache/iceberg-go/pull/2005#discussion_r4007846878
##########
table/snapshot_producers.go:
##########
@@ -912,6 +1090,24 @@ func (sp *snapshotProducer) assembleManifests(ctx
context.Context, parent *Snaps
// files are inherited and the removed files are actually dropped — grafting
the
// attempt-0 result onto a fresh parent would resurrect the removed files.
func (sp *snapshotProducer) parentDependentManifests(ctx context.Context,
parent *Snapshot) (_ []iceberg.ManifestFile, err error) {
+ if of, ok := sp.producerImpl.(*overwriteFiles); ok {
+ parentManifests, err := of.filterAndRewriteParentManifests(ctx,
parent)
+ if err != nil {
+ return nil, err
+ }
+
+ deletedManifests, err :=
sp.writeDeletedEntries(parentManifests.deleted)
+ if err != nil {
+ if cleanupErr :=
sp.cleanupGeneratedManifests(parentManifests.rewritten); cleanupErr != nil {
Review Comment:
This second-stage cleanup has no test: `filterAndRewriteParentManifests`
succeeds and writes new manifests, then `writeDeletedEntries` fails and we
clean up `parentManifests.rewritten`.
`TestOverwriteExistingManifestsCleansCompletedRewritesOnError` covers a failure
inside `filterAndRewriteParentManifests`, but this branch is structurally
different, and if `rewritten` is ever mispopulated we silently leak the
rewritten manifests.
I'd add a test that injects a write failure into `writeDeletedEntries` after
a successful rewrite pass and asserts no files containing `sp.commitUuid`
survive.
##########
table/snapshot_producers.go:
##########
@@ -912,6 +1090,24 @@ func (sp *snapshotProducer) assembleManifests(ctx
context.Context, parent *Snaps
// files are inherited and the removed files are actually dropped — grafting
the
// attempt-0 result onto a fresh parent would resurrect the removed files.
func (sp *snapshotProducer) parentDependentManifests(ctx context.Context,
parent *Snapshot) (_ []iceberg.ManifestFile, err error) {
+ if of, ok := sp.producerImpl.(*overwriteFiles); ok {
Review Comment:
I think this dispatch is the thing I'd most want to resolve before merge.
The type assertion here routes every overwrite commit into
`filterAndRewriteParentManifests`, so `overwriteFiles.existingManifests` and
`deletedEntries` are never reached in production anymore, including on OCC
retries, since `assembleManifests` comes back through here. That leaves two
implementations of the same filtering logic (the concurrent one in
`filterAndRewriteParentManifests`, and the one behind the interface methods)
kept in sync by hand, with nothing at the compiler level enforcing it. A future
`scanManifest` fix has to be consciously mirrored, and any producer embedding
`overwriteFiles` silently gets the old two-scan path.
A couple of ways to handle it, no strong preference:
Fold both into a single interface method that returns survivors and
tombstones together (basically expose `overwriteParentManifests` through
`producerImpl`), so the concurrent path is the only path and the interface is
what production dispatches on.
Or, if the type-switch is meant to be the canonical routing, delete the
now-dead `existingManifests`/`deletedEntries` bodies on `overwriteFiles` and
leave a comment here saying overwrite is handled via
`filterAndRewriteParentManifests`. Right now the interface doc on
`existingManifests` still says it's re-evaluated on every OCC retry and must
read parent, which is misleading for a method production never calls.
Either is fine, but I'd like the two collapsed to one before this lands so
we're not maintaining parallel filtering logic. wdyt?
##########
table/snapshot_producers_test.go:
##########
@@ -1269,13 +1269,165 @@ func
TestOverwriteExistingManifestsClosesUnderlyingFile(t *testing.T) {
trackIO.writers = make(map[string]*trackingWriteCloser)
- _, err = sp.existingManifests(&snap)
+ _, err = sp.existingManifests(context.Background(), &snap)
require.NoError(t, err, "existingManifests should succeed")
unclosed := trackIO.GetUnclosedWriters()
require.Empty(t, unclosed, "all file writerFactory should be closed
after existingManifests, but these are still open: %v", unclosed)
}
+func TestOverwriteExistingManifestsLimitsConcurrencyAndPreservesOrder(t
*testing.T) {
+ const (
+ concurrency = 2
+ manifestCount = 4
+ )
+
+ spec := partitionedSpec()
+ schema := simpleSchema()
+ blockingIO := newBlockingCreateIO(1<<20, nil, concurrency)
+ defer blockingIO.Release()
+ txn := createTestTransaction(t, blockingIO, spec)
+ sp := newOverwriteFilesProducer(OpOverwrite, txn, blockingIO, nil, nil)
+ of := sp.producerImpl.(*overwriteFiles)
+ of.manifestConcurrency = concurrency
+
+ snapshotID := int64(100)
+ sequenceNumber := int64(-1)
+ manifestSequenceNumber := int64(42)
+ manifests := make([]iceberg.ManifestFile, 0, manifestCount)
+ for i := range manifestCount {
+ deletedFile := newTestDataFile(t, spec,
"file://deleted-"+strconv.Itoa(i)+".parquet", nil)
+ keptFile := newTestDataFile(t, spec,
"file://kept-"+strconv.Itoa(i)+".parquet", nil)
+ sp.deleteDataFile(deletedFile)
+
+ entries := []iceberg.ManifestEntry{
+ iceberg.NewManifestEntry(iceberg.EntryStatusADDED,
&snapshotID, &manifestSequenceNumber, nil, deletedFile),
+ iceberg.NewManifestEntry(iceberg.EntryStatusADDED,
&snapshotID, &manifestSequenceNumber, nil, keptFile),
+ }
+ path := "table-location/metadata/source-" + strconv.Itoa(i) +
".avro"
+ manifests = append(manifests, writeTestManifestWithEntries(t,
blockingIO, spec, schema, snapshotID, path, entries))
+ }
+
+ manifestListPath := "table-location/metadata/snap-1.avro"
+ var listBuf bytes.Buffer
+ err := iceberg.WriteManifestList(2, &listBuf, snapshotID, nil,
&sequenceNumber, 0, manifests)
+ require.NoError(t, err, "write manifest list")
+ require.NoError(t, blockingIO.WriteFile(manifestListPath,
listBuf.Bytes()))
+
+ snap := Snapshot{
+ SnapshotID: snapshotID,
+ SequenceNumber: sequenceNumber,
+ ManifestList: manifestListPath,
+ }
+ txn.meta.snapshotList = []Snapshot{snap}
+ txn.meta.currentSnapshotID = &snapshotID
+
+ type result struct {
+ manifests []iceberg.ManifestFile
+ err error
+ }
+ done := make(chan result, 1)
+ go func() {
+ got, err := sp.existingManifests(context.Background(), &snap)
+ done <- result{manifests: got, err: err}
+ }()
+
+ select {
+ case <-blockingIO.reached:
+ case <-time.After(time.Second):
+ require.FailNow(t, "timed out waiting for overwrite manifest
workers")
+ }
+ time.Sleep(50 * time.Millisecond)
Review Comment:
The 50ms sleep makes this assertion racy. `blockingIO.reached` already fires
once `concurrency` workers are inside `Create`, and any extra goroutine blocks
on the semaphore before it can call `Create`, so `MaxActive()` is already
exactly `concurrency` the moment `reached` fires. On a slow CI box the sleep
can expire before anything extra gets scheduled and the test passes for the
wrong reason.
I'd drop the sleep, assert `MaxActive()` right after `reached`, then
`Release()`.
##########
table/snapshot_producers_test.go:
##########
@@ -1269,13 +1269,165 @@ func
TestOverwriteExistingManifestsClosesUnderlyingFile(t *testing.T) {
trackIO.writers = make(map[string]*trackingWriteCloser)
- _, err = sp.existingManifests(&snap)
+ _, err = sp.existingManifests(context.Background(), &snap)
require.NoError(t, err, "existingManifests should succeed")
unclosed := trackIO.GetUnclosedWriters()
require.Empty(t, unclosed, "all file writerFactory should be closed
after existingManifests, but these are still open: %v", unclosed)
}
+func TestOverwriteExistingManifestsLimitsConcurrencyAndPreservesOrder(t
*testing.T) {
+ const (
+ concurrency = 2
+ manifestCount = 4
+ )
+
+ spec := partitionedSpec()
+ schema := simpleSchema()
+ blockingIO := newBlockingCreateIO(1<<20, nil, concurrency)
+ defer blockingIO.Release()
+ txn := createTestTransaction(t, blockingIO, spec)
+ sp := newOverwriteFilesProducer(OpOverwrite, txn, blockingIO, nil, nil)
+ of := sp.producerImpl.(*overwriteFiles)
+ of.manifestConcurrency = concurrency
+
+ snapshotID := int64(100)
+ sequenceNumber := int64(-1)
+ manifestSequenceNumber := int64(42)
+ manifests := make([]iceberg.ManifestFile, 0, manifestCount)
+ for i := range manifestCount {
+ deletedFile := newTestDataFile(t, spec,
"file://deleted-"+strconv.Itoa(i)+".parquet", nil)
+ keptFile := newTestDataFile(t, spec,
"file://kept-"+strconv.Itoa(i)+".parquet", nil)
+ sp.deleteDataFile(deletedFile)
+
+ entries := []iceberg.ManifestEntry{
+ iceberg.NewManifestEntry(iceberg.EntryStatusADDED,
&snapshotID, &manifestSequenceNumber, nil, deletedFile),
+ iceberg.NewManifestEntry(iceberg.EntryStatusADDED,
&snapshotID, &manifestSequenceNumber, nil, keptFile),
+ }
+ path := "table-location/metadata/source-" + strconv.Itoa(i) +
".avro"
+ manifests = append(manifests, writeTestManifestWithEntries(t,
blockingIO, spec, schema, snapshotID, path, entries))
+ }
+
+ manifestListPath := "table-location/metadata/snap-1.avro"
+ var listBuf bytes.Buffer
+ err := iceberg.WriteManifestList(2, &listBuf, snapshotID, nil,
&sequenceNumber, 0, manifests)
+ require.NoError(t, err, "write manifest list")
+ require.NoError(t, blockingIO.WriteFile(manifestListPath,
listBuf.Bytes()))
+
+ snap := Snapshot{
+ SnapshotID: snapshotID,
+ SequenceNumber: sequenceNumber,
+ ManifestList: manifestListPath,
+ }
+ txn.meta.snapshotList = []Snapshot{snap}
+ txn.meta.currentSnapshotID = &snapshotID
+
+ type result struct {
+ manifests []iceberg.ManifestFile
+ err error
+ }
+ done := make(chan result, 1)
+ go func() {
+ got, err := sp.existingManifests(context.Background(), &snap)
Review Comment:
The new correctness and ordering coverage all drives `sp.existingManifests`
directly, but production takes the `filterAndRewriteParentManifests` path from
`parentDependentManifests`, so these tests exercise a branch overwrite commits
never hit. The only thing touching the real path is the benchmark, and it just
asserts no error.
I'd add a test that calls `sp.parentDependentManifests` (or runs a full
overwrite/delete commit) on an `*overwriteFiles` producer and asserts the
actual output: surviving manifests hold only the kept entries, a delete
manifest is written for the removed files, the delete manifests sort ahead of
the existing ones, and the tombstone entries carry the right snapshot id and
sequence numbers. That's the path that ships.
##########
table/transaction.go:
##########
@@ -2450,6 +2450,7 @@ func (t *Transaction) performCopyOnWriteDeletion(ctx
context.Context, operation
commitUUID := uuid.New()
updater := t.updateSnapshot(wfs, snapshotProps,
operation).mergeOverwrite(&commitUUID, filter)
+ updater.producerImpl.(*overwriteFiles).manifestConcurrency = concurrency
Review Comment:
This assertion (and the matching one in `performMergeOnReadDeletion` at line
2501) panics if `mergeOverwrite` ever returns a different producer type, and it
reaches past the constructor to set a field directly.
The two existing `producerImpl.(*overwriteFiles)` sites carry a `//
mergeOverwrite guarantees an *overwriteFiles producerImpl.` comment, so I'd at
least add that here too. Better still, expose `manifestConcurrency` as an
unexported option or setter on `newOverwriteFilesProducer` and go through that.
##########
table/snapshot_producers.go:
##########
@@ -912,6 +1090,24 @@ func (sp *snapshotProducer) assembleManifests(ctx
context.Context, parent *Snaps
// files are inherited and the removed files are actually dropped — grafting
the
// attempt-0 result onto a fresh parent would resurrect the removed files.
func (sp *snapshotProducer) parentDependentManifests(ctx context.Context,
parent *Snapshot) (_ []iceberg.ManifestFile, err error) {
+ if of, ok := sp.producerImpl.(*overwriteFiles); ok {
+ parentManifests, err := of.filterAndRewriteParentManifests(ctx,
parent)
+ if err != nil {
+ return nil, err
+ }
+
+ deletedManifests, err :=
sp.writeDeletedEntries(parentManifests.deleted)
Review Comment:
Not a blocker, and I think it's fine for the common case, but worth
flagging: previously `writeDeletedEntries` ran concurrently with the
existing-manifest scan in its own goroutine, and here it's serialized after
`filterAndRewriteParentManifests` returns. For a table with few manifests but
many deleted entries, the tombstone write is now on the critical path where it
used to overlap.
Probably not worth pipelining now. Could we drop a comment noting the
trade-off so it reads as a deliberate choice? wdyt?
##########
table/snapshot_producers.go:
##########
@@ -924,92 +1120,115 @@ func (sp *snapshotProducer)
parentDependentManifests(ctx context.Context, parent
if len(deleted) > 0 {
g.Go(func() error {
- // Group deleted entries by (specID, contentType) to
ensure data and
- // delete file entries are written to separate
manifests with the
- // correct ManifestContent.
- type groupKey struct {
- specID int
- content iceberg.ManifestContent
- }
- groups := map[groupKey][]iceberg.ManifestEntry{}
- for _, entry := range deleted {
- content := iceberg.ManifestContentData
- if entry.DataFile().ContentType() !=
iceberg.EntryContentData {
- content = iceberg.ManifestContentDeletes
- }
- key := groupKey{specID:
int(entry.DataFile().SpecID()), content: content}
- groups[key] = append(groups[key], entry)
- }
+ var err error
+ deletedFilesManifests, err =
sp.writeDeletedEntries(deleted)
- writeGroup := func(key groupKey, entries
[]iceberg.ManifestEntry) (_ iceberg.ManifestFile, retErr error) {
- spec, err := sp.spec(key.specID)
- if err != nil {
- return nil, err
- }
+ return err
+ })
+ }
- wr, path, counter, out, err :=
sp.newManifestWriter(spec,
-
iceberg.WithManifestWriterContent(key.content))
- if err != nil {
- return nil, err
- }
- defer internal.CheckedClose(out, &retErr)
+ g.Go(func() error {
+ m, err := sp.existingManifests(ctx, parent)
+ if err != nil {
+ return err
+ }
+ existingManifests = m
- writerClosed := false
- defer func() {
- if !writerClosed {
- internal.CheckedClose(wr,
&retErr)
- }
- }()
+ return nil
+ })
- for _, entry := range entries {
- if err := wr.Delete(entry); err != nil {
- return nil, err
- }
- }
+ if err := g.Wait(); err != nil {
+ return nil, err
+ }
- writerClosed = true
- if err := wr.Close(); err != nil {
- return nil, err
- }
+ return slices.Concat(deletedFilesManifests, existingManifests), nil
+}
- return wr.ToManifestFile(path, counter.Count,
iceberg.WithManifestFileContent(key.content))
- }
+func (sp *snapshotProducer) writeDeletedEntries(deleted
[]iceberg.ManifestEntry) (_ []iceberg.ManifestFile, retErr error) {
Review Comment:
`writeDeletedEntries` does all its manifest I/O without a context, so once
we're in here the writes can't be cancelled. In the overwrite fast path it runs
after `filterAndRewriteParentManifests`, so a context that's already been
cancelled by then won't stop it, and every other I/O function in this PR takes
and checks `ctx`.
I'd thread `ctx` through here and into `writeGroup`, with a
`context.Cause(ctx)` check in the key loop, matching `rewriteManifest`.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]