Repository navigation
[Bug]: CN insert and multi_update memory-pressure S3 flushes orphan objects when a later error never registers them on the transaction #29257
Description
Is there an existing issue for the same bug?
- I have checked the existing issues.
Branch Name
main
Commit ID
Other Environment Information
- Found by reading
origin/mainat the commit above. Not reproduced with a live SQL session. - Hardware parameters: code inspection
- OS type: n/a
- Others: CN insert / multi_update memory-pressure flush versus the TN sinker cleanup contract
Actual Behavior
A CN INSERT or multi_update can upload object-storage objects during a memory-pressure flush and then leak them when a later error aborts the statement. The statement itself fails. The objects are never registered on the transaction, and nothing deletes them.
Sinker.Write → pushStaged calls trySpill once inMemorySize >= memorySizeThreshold (pkg/objectio/ioutil/sinker.go). CN writers set that threshold from WriteS3Threshold (128 * mpool.MB, pkg/sql/colexec/s3util.go). A successful syncFileSinker appends the object stats to staged.persisted.
Sinker.Close then drops that ownership without deleting:
sinker.staged.persisted = nil
sinker.staged.unpublished = nilClose does not call DeletePersisted. DeletePersisted is the owner that deletes staged.unpublished, staged.persisted, result.persisted, and pipeline persisted/unpublished names. Its production callers are TN checkpoint (pkg/vm/engine/tae/logtail/ckp_writer.go), publication (pkg/publication/filter_object.go), and transferred tombstone (pkg/vm/engine/tae/txn/txnimpl/transferred_tombstone_sink.go). CN insert and multi_update are not among them.
syncFileSinker records staged.unpublished only for the active writer name when the current Sync fails. Objects already appended to staged.persisted by an earlier successful spill are not in that list. Close discards the unpublished list too, so even the commit-ambiguous current file is not deleted on the CN path. RecordUnpublishedObjectCleanup (restart-safe marker before the object exists) is only called from TN GC PrepareUnpublishedObject (pkg/vm/engine/tae/db/gc/v3/checkpoint.go). The CN BlockWriter / FSinker path does not record it.
Two CN call sites make the window worse by detaching stats before the caller has stored them:
CNS3Writer.SyncAndFillBlockInfoBatcallsSyncAndTakeResultsfirst. That nilsresult.persistedand returns the stats. IfExpandObjectStatsToBatchthen fails, the function returns the error and the stats are not returned to the caller (pkg/sql/colexec/s3util.go).Insert.flushS3WriterOnMemoryPressurereturns that error (pkg/sql/colexec/insert/insert.go).Insert.Reset/Insert.Freecalls3Writer.Close(). The objects are in neither the sinker nortxn.writes.- The same flush returns if
ctr.buf.Appendfails after a successfulSyncAndFillBlockInfoBat. The block-info batch is local. It is not appended toctr.buf, soflushTailnever hands those objects toWriteFile.
multi_update has the same class of leak. sortAndSyncOneTable creates a local CNS3Writer and defer s3Writer.Close() (pkg/sql/colexec/multi_update/s3writer_delegate.go). Write can trySpill into staged.persisted. A later Write, Sync, or FillBlockInfoBat error returns before fillDeleteBlockInfo / fillInsertBlockInfo. Close nils the names. flushTailAndWriteToOutput also Syncs persistent insert sinkers; a later table's error returns before those stats are emitted to the output batch, and s3WriterDelegate.reset calls CNS3Writer.Reset, not DeletePersisted.
Rollback cannot see these objects. Transaction.gcObjsByIdxRange (pkg/vm/engine/disttae/types.go) only walks txn.writes entries that already have fileName and an object-stats batch. Objects that never reached WriteFile are not in that slice. GC Verify (pkg/vm/engine/tae/db/gc/v3/check.go) lists filesystem objects missing from checkpoints and the catalog window, logs them as not found, and returns a diagnostic string. It does not delete them. Ordinary GC only removes objects that entered catalog metadata and aged out.
Expected Behavior
If a CN insert or multi_update uploads an object and a later error prevents that object from being registered on the transaction, the CN owner must delete it before dropping the reference. That is the same contract DeletePersisted already implements for TN checkpoint, publication, and transferred tombstone. SyncAndTakeResults must not drop sinker ownership until the caller has either registered the stats or deleted them. A failed statement must not leave permanent objects that no catalog entry and no rollback GC will ever name.
Steps to Reproduce
Code path on main @ 66da3877e9. No live cluster run.
INSERTenough rows throughInsert.insert_s3thatSinker.pushStagedcrossesWriteS3Threshold(128MiB) andtrySpill→syncFileSinkerappends stats tostaged.persisted.- Fail the statement after that upload and before
WriteFile: cancel the context, fail a laterWrite, or failctr.buf.AppendinsideflushS3WriterOnMemoryPressureafterSyncAndFillBlockInfoBathas already calledSyncAndTakeResults. - Observe
Insert.Free→CNS3Writer.Close→Sinker.Closeassignstaged.persisted = nilandstaged.unpublished = nilwith nofs.Delete. - For
UPDATE/DELETEviaMultiUpdate.update_s3, repeat withsortAndSyncOneTable: one table'sWritespills, a laterSyncor a later table'sWritereturns an error, anddefer s3Writer.Close()dropsstaged.persisted. - List the data filesystem after the transaction rolls back. The spilled object name is still present.
gcObjsByIdxRangehas notxn.writesentry for it.
Additional information
- This is not
#25204. That issue tracksworkspaceSize/ counter accounting (D1–D3, repair landed as#26581) and D4: an asyncfs.Deletefailure that is only logged for objects already ontxn.writes. The objects in this report never reachWriteFile, so D4's delete path never observes them.#25204is deferred as a GC-reliability design and does not ask CN insert /multi_updateto callDeletePersisted. - This is not
#27627. These paths returnmoerr.NewInternalErrorfor the sinkerWrite/Syncerror. They do not surface*ExecutionResourceError/ MySQL20101. - The SQL statement is fail-closed. The bug is the surviving object, not a partial committed insert.
syncFileSinkeron a failedSyncrecords onlyActiveObjectName()of the file that just failed. Earlier successful spills instaged.persistedare a separate, already-uploaded set. Both sets are dropped byClose.
Activity
Ariznawlll commentedon Sep 22, 2026
Additional CN callers of the same orphan, still on
66da3877e9e77f01083c4aac0cb9f94ab6e11fe6. Not a new bug. Statement or dump still fails closed; ordinary GC still never sees objects that did not reachtxn.writes.Sinker.Close(pkg/objectio/ioutil/sinker.goaround 1205) still nilsstaged.persistedandstaged.unpublishedand does not callDeletePersisted.DeletePersistedproduction callers are unchanged (TN checkpoint, publication filter, transferred tombstone).RecordUnpublishedObjectCleanupis still TN-only. These paths all useWithTailSizeCap(0), so a successfulSinker.Synchas already moved spilled objects intoresult.persistedbefore the caller can register them.Remote deletion flush
pkg/sql/colexec/deletion/types.goflush(about 238–315):newDeletionTombstoneWriter→NewCNS3TombstoneWriterForService(..., -1), so the spill threshold isWriteS3Threshold(128MiB) and the tail cap is 0.CNS3Writerperpidx. ProductionSplitBatchalways passespIdx == 0, so this is the normal remote DELETE path, not a SQLPARTITIONpath.defer s3writer.Close()is inside the loop.go.modisgo 1.26.4, so each iteration has its owns3writerand every writer is closed whenflushreturns. This is not a last-writer-only close bug. Close still does not delete objects.flushThresholdis 5MiB, butcollectBatchInfoabsorbs the whole input before the check, so one DELETE batch can push a single writer throughtrySpill.Writeerror after an earliersyncFileSinkersuccess leaves that object instaged.persisted.Syncreturns at the tailtrySpillerror (aboutsinker.go1106) before the defer that clearsstaged.persisted, so those earlier objects are still tracked, thenClosedrops the names. A failed tailSyncalso recordsActiveObjectName()onstaged.unpublishedandClosedrops that too.CNS3Writer.Syncissinker.SyncplusGetResult. It does not take ownership out. Stats are stored inpartitionId_tombstoneObjectStatsBatsonly afterAppendBytes.remoteDelete(deletion.go112–114) returns the flush error, soEval(155–188), the only publisher of those stats, never runs.Free(197–213) nilspartitionId_tombstoneObjectStatsBatsand does not delete objects.collectBatchInfostill ignoresAppendFixed/AppendAnyafterbitmap.Add. That batch is on-heapvector.NewVec(makeDelBatch). That is not this orphan, and it is not a practical silent tombstone drop under the 5MiB flush.Workspace dump, object rewrite, clone reader, CN tombstone transfer
Same
ClosewithoutDeletePersistedafter a successfulSyncand beforewriteFileLocked/ returning the stats:Transaction.dumpInsertBatchLockedanddumpDeleteBatchLocked(pkg/vm/engine/disttae/txn.goabout 1141–1193 and 1284–1340).defer s3Writer.Close().Write/Sync/FillBlockInfoBaterrors return beforewriteFileLockedWithAutoIncrEpochKnown. A later table in the loop can fail after an earlier table was registered; only the unregistered writer leaks. Rollback GC can see the registered ones.txnTable.rewriteObjectByDeletion(txn_table.goabout 2127–2200).defer s3Writer.Close(). The caller (txn.goabout 2371)panics on error and alsopanics ifwriteFileLockedfails after rewrite returned. Neither path deletes the objects rewrite already synced.TableMetaReader.collectDataOfAObjsAndInMem/collectTombstoneOfAObjsAndInMem(table_meta_reader.goabout 450–457 and 517–525) defers3Writer.Close().readWriteHelper(614–671) syncs, thenFillBlockInfoBat, thenoutBatch.Append. On error the parent returnsnil, err(about 374–386). The new objects are not passed toaddCloneSharedFile(that list is only for objects already being cloned).transferTombstoneObjects(transfer.goabout 114–160).defer flow.Close()→TransferFlow.Close→sinker.Close()(transfer_util.go361–368). The sinker is built with a 16MiB threshold and tail cap 0 (transfer_util.go225–234).Processends insinker.Sync. Objects are registered only by the laterwriteFileLockedWithAutoIncrEpoch.writeFileLockedWithAutoIncrEpochKnown(txn.go1609–1620) can return beforeappendWorkspaceEntryLocked: the auto-increment epoch fence (requireAutoIncrEpochFenceCommit, and this transfer wrapper passesautoIncrEpochKnown=true) orinputBat.Dup. Those errors leaveresult.persistedonly on the sinker, andClosedrops it.writeFileLockedWithAutoIncrEpochKnownreturns nil once the entry is appended, so the hole is the post-Syncpre-append failure, not a rollback of an entry that was already stored.XuPeng-SH commentedon Sep 23, 2026
Confirmed and incorporated in PR #29290.
The additional callers in this comment share the same root cause: S3 persistence completes before the corresponding metadata is appended to the transaction workspace. If a later step fails,
Sinker.Closedrops the only object-name references, while rollback GC can only inspect entries already present intxn.writes.The PR now covers the remote DELETE flush, transaction insert/delete dumps, deletion rewrite, clone reader outputs, and tombstone transfer. Remote multi_update also hands copied object-name ownership to the coordinator before forwarding each output batch; the worker releases its lease only after the ACK drain. The repeated-spill memory review is addressed separately by closing each completed one-shot writer promptly and retaining only lightweight object-name ownership.
Affected package suites, focused ownership tests under
-race,go vet, and incremental lint passed. There is still no live SQL/cluster reproduction; the failure window is covered by deterministic white-box failure injection.