laskoviymishka commented on code in PR #1851:
URL: https://github.com/apache/iceberg-go/pull/1851#discussion_r3833959796


##########
table/rolling_data_writer.go:
##########
@@ -430,6 +442,31 @@ func (r *RollingDataWriter) stream(outputDataFilesCh 
chan<- iceberg.DataFile) {
                if currentWriter != nil {
                        _ = currentWriter.Abort()
                }
+               // stream is exiting; this defer runs before the 
CompareAndDelete
+               // defer above deregisters the writer, so cleanup does not 
depend on
+               // abortAll finding this writer in the registry: by the time (if
+               // ever) abortAll looks, this writer is already gone. Cancel 
first so
+               // any Add call still in flight from another fanout worker 
prefers
+               // bailing out via ctx.Done() (see Add's priority check) over 
queuing
+               // into a channel nobody will drain again, then release 
whatever is
+               // already buffered right now. recordCh is never closed here: 
other
+               // callers may still be sending to it, and closing while a send 
can
+               // still land would risk a send-on-closed-channel panic. That 
leaves
+               // a narrow window between this drain and Add's ctx.Done() check
+               // taking effect elsewhere, but it is bounded to that one window
+               // rather than persisting for the life of the writer.
+               r.cancel()

Review Comment:
   This defer runs on every `stream()` exit, including the clean channel-closed 
path, so `r.cancel()` now fires from the stream goroutine on success too, 
before `wg.Done()` unblocks `closeAndWait`. It's idempotent today 
(`closeAndWait` cancels again), but it's a quiet semantic shift: the per-writer 
context is now cancelled "by stream" on the happy path, not "by closeAndWait", 
so anything that later checks `r.ctx.Err()` after `wg.Wait()` returns would see 
a cancelled context on a successful write. If we keep this shape I'd guard it 
behind an exited-cleanly flag so the cancel only fires on the error exit.



##########
table/rolling_data_writer.go:
##########
@@ -430,6 +442,31 @@ func (r *RollingDataWriter) stream(outputDataFilesCh 
chan<- iceberg.DataFile) {
                if currentWriter != nil {
                        _ = currentWriter.Abort()
                }
+               // stream is exiting; this defer runs before the 
CompareAndDelete
+               // defer above deregisters the writer, so cleanup does not 
depend on
+               // abortAll finding this writer in the registry: by the time (if
+               // ever) abortAll looks, this writer is already gone. Cancel 
first so
+               // any Add call still in flight from another fanout worker 
prefers
+               // bailing out via ctx.Done() (see Add's priority check) over 
queuing
+               // into a channel nobody will drain again, then release 
whatever is
+               // already buffered right now. recordCh is never closed here: 
other

Review Comment:
   Small thing, but "recordCh is never closed here" reads like the `!ok` case a 
few lines down is dead code. It isn't: `closeInput()` closes the channel and, 
since `wg.Done()` is the last defer here (LIFO), it can run while this drain is 
still looping. So `!ok` is genuinely reachable, and someone could prune it as 
dead code and turn the drain into a spin on a closed channel. I'd reword to say 
another goroutine may close `recordCh` via `closeInput`/abort and that `!ok` is 
what ends the drain.
   
   While here: this comment block is much denser than the rest of the file, 
where the method bodies are mostly one line or none. I'd trim it to the intent 
plus the one real constraint (don't close recordCh here) and let the code carry 
the rest.



##########
table/rolling_data_writer_test.go:
##########
@@ -905,3 +905,60 @@ func (s *RollingDataWriterTestSuite) 
TestStreamRecoversWriterClosePanic() {
                })
        }
 }
+
+// failingOpenFormat always fails to open a file writer, simulating a
+// mid-stream write error (e.g. an unwritable location) that makes stream
+// return before it ever dequeues most of what's already buffered in recordCh.
+type failingOpenFormat struct {
+       tblutils.FileFormat
+}
+
+func (failingOpenFormat) NewFileWriter(context.Context, iceio.WriteFileIO, 
map[int]any, tblutils.WriteFileInfo, *arrow.Schema) (tblutils.FileWriter, 
error) {
+       return nil, errors.New("simulated open failure")
+}
+
+// TestStreamErrorDrainsBufferedRecords reproduces the leak from #1825: when
+// stream exits early on a write error, records already sitting in recordCh —
+// queued by Add before stream's first dequeue attempt fails — must still be
+// released. Before the fix, stream's deferred CompareAndDelete deregisters the
+// writer before abortAll ever has a chance to run, so nothing drains what's
+// left in recordCh. Records are queued directly, bypassing Add and the
+// goroutine start in newRollingDataWriter, so all of them are buffered before
+// stream ever runs — otherwise Add would race stream's error exit for the
+// later records once errorCh closes.
+func (s *RollingDataWriterTestSuite) TestStreamErrorDrainsBufferedRecords() {
+       arrSchema := arrow.NewSchema([]arrow.Field{
+               {Name: "id", Type: arrow.PrimitiveTypes.Int32, Nullable: true},
+               {Name: "name", Type: arrow.BinaryTypes.String, Nullable: true},
+       }, nil)
+
+       loc := filepath.ToSlash(s.T().TempDir())
+       factory, _ := s.createWriterFactory(loc, arrSchema, 1024*1024)
+       factory.format = failingOpenFormat{FileFormat: factory.format}
+
+       outputCh := make(chan iceberg.DataFile, 10)
+       ctx, cancel := context.WithCancel(s.ctx)

Review Comment:
   Missing the `defer cancel()` that both other `WithCancel` tests in this file 
pair with. `stream`'s cleanup does call `r.cancel()`, but only once the 
goroutine is running, so if the test bails between here and `go 
writer.stream(...)` the context leaks (and `go vet`/lint flags the missing 
cancel). One line right below.



##########
table/rolling_data_writer_test.go:
##########
@@ -905,3 +905,60 @@ func (s *RollingDataWriterTestSuite) 
TestStreamRecoversWriterClosePanic() {
                })
        }
 }
+
+// failingOpenFormat always fails to open a file writer, simulating a
+// mid-stream write error (e.g. an unwritable location) that makes stream
+// return before it ever dequeues most of what's already buffered in recordCh.
+type failingOpenFormat struct {
+       tblutils.FileFormat
+}
+
+func (failingOpenFormat) NewFileWriter(context.Context, iceio.WriteFileIO, 
map[int]any, tblutils.WriteFileInfo, *arrow.Schema) (tblutils.FileWriter, 
error) {
+       return nil, errors.New("simulated open failure")
+}
+
+// TestStreamErrorDrainsBufferedRecords reproduces the leak from #1825: when
+// stream exits early on a write error, records already sitting in recordCh —
+// queued by Add before stream's first dequeue attempt fails — must still be
+// released. Before the fix, stream's deferred CompareAndDelete deregisters the
+// writer before abortAll ever has a chance to run, so nothing drains what's
+// left in recordCh. Records are queued directly, bypassing Add and the
+// goroutine start in newRollingDataWriter, so all of them are buffered before
+// stream ever runs — otherwise Add would race stream's error exit for the
+// later records once errorCh closes.
+func (s *RollingDataWriterTestSuite) TestStreamErrorDrainsBufferedRecords() {
+       arrSchema := arrow.NewSchema([]arrow.Field{
+               {Name: "id", Type: arrow.PrimitiveTypes.Int32, Nullable: true},
+               {Name: "name", Type: arrow.BinaryTypes.String, Nullable: true},
+       }, nil)
+
+       loc := filepath.ToSlash(s.T().TempDir())
+       factory, _ := s.createWriterFactory(loc, arrSchema, 1024*1024)

Review Comment:
   `createWriterFactory` calls `iter.Pull`, which starts a goroutine that only 
stops via `stopCount()` inside `closeAll()`/`abortAll()`. Every other test in 
this file that builds a factory pairs it with `defer factory.closeAll()`; this 
one doesn't, so the pull goroutine leaks for the rest of the run (a goleak 
check would flag it). I'd add `defer factory.closeAll()` right here. No writer 
is registered on this factory, so it just calls `stopCount()`.



-- 
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]

Reply via email to