Skip to content

[Bug]: CN insert and multi_update memory-pressure S3 flushes orphan objects when a later error never registers them on the transaction #29257

Description

@Ariznawlll

Is there an existing issue for the same bug?

  • I have checked the existing issues.

Branch Name

main

Commit ID

66da387

Other Environment Information

  • Found by reading origin/main at 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 = nil

Close 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:

  1. CNS3Writer.SyncAndFillBlockInfoBat calls SyncAndTakeResults first. That nils result.persisted and returns the stats. If ExpandObjectStatsToBatch then fails, the function returns the error and the stats are not returned to the caller (pkg/sql/colexec/s3util.go). Insert.flushS3WriterOnMemoryPressure returns that error (pkg/sql/colexec/insert/insert.go). Insert.Reset / Insert.Free call s3Writer.Close(). The objects are in neither the sinker nor txn.writes.
  2. The same flush returns if ctr.buf.Append fails after a successful SyncAndFillBlockInfoBat. The block-info batch is local. It is not appended to ctr.buf, so flushTail never hands those objects to WriteFile.

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.

  1. INSERT enough rows through Insert.insert_s3 that Sinker.pushStaged crosses WriteS3Threshold (128MiB) and trySpill → syncFileSinker appends stats to staged.persisted.
  2. Fail the statement after that upload and before WriteFile: cancel the context, fail a later Write, or fail ctr.buf.Append inside flushS3WriterOnMemoryPressure after SyncAndFillBlockInfoBat has already called SyncAndTakeResults.
  3. Observe Insert.Free → CNS3Writer.Close → Sinker.Close assign staged.persisted = nil and staged.unpublished = nil with no fs.Delete.
  4. For UPDATE/DELETE via MultiUpdate.update_s3, repeat with sortAndSyncOneTable: one table's Write spills, a later Sync or a later table's Write returns an error, and defer s3Writer.Close() drops staged.persisted.
  5. List the data filesystem after the transaction rolls back. The spilled object name is still present. gcObjsByIdxRange has no txn.writes entry for it.

Additional information

  • This is not #25204. That issue tracks workspaceSize / counter accounting (D1–D3, repair landed as #26581) and D4: an async fs.Delete failure that is only logged for objects already on txn.writes. The objects in this report never reach WriteFile, so D4's delete path never observes them. #25204 is deferred as a GC-reliability design and does not ask CN insert / multi_update to call DeletePersisted.
  • This is not #27627. These paths return moerr.NewInternalErrorf or the sinker Write/Sync error. They do not surface *ExecutionResourceError / MySQL 20101.
  • The SQL statement is fail-closed. The bug is the surviving object, not a partial committed insert.
  • syncFileSinker on a failed Sync records only ActiveObjectName() of the file that just failed. Earlier successful spills in staged.persisted are a separate, already-uploaded set. Both sets are dropped by Close.

Activity

  1. added
    kind/bugSomething isn't working
    needs-triageNeeds evaluation before prioritization. Not yet decided whether to proceed
    on Sep 22, 2026
  2. Ariznawlll commented on Sep 22, 2026

    @Ariznawlll
    CollaboratorAuthor

    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 reach txn.writes.

    Sinker.Close (pkg/objectio/ioutil/sinker.go around 1205) still nils staged.persisted and staged.unpublished and does not call DeletePersisted. DeletePersisted production callers are unchanged (TN checkpoint, publication filter, transferred tombstone). RecordUnpublishedObjectCleanup is still TN-only. These paths all use WithTailSizeCap(0), so a successful Sinker.Sync has already moved spilled objects into result.persisted before the caller can register them.

    Remote deletion flush

    pkg/sql/colexec/deletion/types.go flush (about 238–315):

    • newDeletionTombstoneWriter → NewCNS3TombstoneWriterForService(..., -1), so the spill threshold is WriteS3Threshold (128MiB) and the tail cap is 0.
    • One CNS3Writer per pidx. Production SplitBatch always passes pIdx == 0, so this is the normal remote DELETE path, not a SQL PARTITION path.
    • defer s3writer.Close() is inside the loop. go.mod is go 1.26.4, so each iteration has its own s3writer and every writer is closed when flush returns. This is not a last-writer-only close bug. Close still does not delete objects.
    • flushThreshold is 5MiB, but collectBatchInfo absorbs the whole input before the check, so one DELETE batch can push a single writer through trySpill.
    • Write error after an earlier syncFileSinker success leaves that object in staged.persisted. Sync returns at the tail trySpill error (about sinker.go 1106) before the defer that clears staged.persisted, so those earlier objects are still tracked, then Close drops the names. A failed tail Sync also records ActiveObjectName() on staged.unpublished and Close drops that too.
    • CNS3Writer.Sync is sinker.Sync plus GetResult. It does not take ownership out. Stats are stored in partitionId_tombstoneObjectStatsBats only after AppendBytes. remoteDelete (deletion.go 112–114) returns the flush error, so Eval (155–188), the only publisher of those stats, never runs.
    • Free (197–213) nils partitionId_tombstoneObjectStatsBats and does not delete objects.

    collectBatchInfo still ignores AppendFixed / AppendAny after bitmap.Add. That batch is on-heap vector.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 Close without DeletePersisted after a successful Sync and before writeFileLocked / returning the stats:

    • Transaction.dumpInsertBatchLocked and dumpDeleteBatchLocked (pkg/vm/engine/disttae/txn.go about 1141–1193 and 1284–1340). defer s3Writer.Close(). Write / Sync / FillBlockInfoBat errors return before writeFileLockedWithAutoIncrEpochKnown. 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.go about 2127–2200). defer s3Writer.Close(). The caller (txn.go about 2371) panics on error and also panics if writeFileLocked fails after rewrite returned. Neither path deletes the objects rewrite already synced.
    • TableMetaReader.collectDataOfAObjsAndInMem / collectTombstoneOfAObjsAndInMem (table_meta_reader.go about 450–457 and 517–525) defer s3Writer.Close(). readWriteHelper (614–671) syncs, then FillBlockInfoBat, then outBatch.Append. On error the parent returns nil, err (about 374–386). The new objects are not passed to addCloneSharedFile (that list is only for objects already being cloned).
    • transferTombstoneObjects (transfer.go about 114–160). defer flow.Close() → TransferFlow.Close → sinker.Close() (transfer_util.go 361–368). The sinker is built with a 16MiB threshold and tail cap 0 (transfer_util.go 225–234). Process ends in sinker.Sync. Objects are registered only by the later writeFileLockedWithAutoIncrEpoch. writeFileLockedWithAutoIncrEpochKnown (txn.go 1609–1620) can return before appendWorkspaceEntryLocked: the auto-increment epoch fence (requireAutoIncrEpochFenceCommit, and this transfer wrapper passes autoIncrEpochKnown=true) or inputBat.Dup. Those errors leave result.persisted only on the sinker, and Close drops it.

    writeFileLockedWithAutoIncrEpochKnown returns nil once the entry is appended, so the hole is the post-Sync pre-append failure, not a rollback of an entry that was already stored.

  3. added this to the MO-202609 milestone on Sep 23, 2026
  4. added theissue type on Sep 23, 2026
  5. self-assigned this
    on Sep 23, 2026
  6. XuPeng-SH commented on Sep 23, 2026

    @XuPeng-SH
    Contributor

    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.Close drops the only object-name references, while rollback GC can only inspect entries already present in txn.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.

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

Metadata

Metadata

Assignees

Labels

kind/bugSomething isn't workingneeds-triageNeeds evaluation before prioritization. Not yet decided whether to proceed

Type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions