Skip to content

[common] Keep the shared _temporary directory while other writers stage there - #8900

Open
sundapeng wants to merge 4 commits into
apache:masterfrom
sundapeng:fix/format-table-staging-dir-clean
Open

[common] Keep the shared _temporary directory while other writers stage there#8900
sundapeng wants to merge 4 commits into
apache:masterfrom
sundapeng:fix/format-table-staging-dir-clean

Conversation

@sundapeng

Copy link
Copy Markdown
Member

Problem

RenamingTwoPhaseOutputStream stages into <target-dir>/_temporary/.tmp.<uuid> — the same directory name Hadoop's FileOutputCommitter uses, and with it Hive, Spark and MapReduce writing to the same location. clean() removed that directory recursively:

public void clean(FileIO fileIO) {
    fileIO.deleteDirectoryQuietly(tempPath.getParent());   // <target-dir>/_temporary
}

clean() runs after every successful commit, so a Paimon write into a directory where another job is mid-write deleted that job's staged files, and the job then failed to commit its own output.

Fix

Delete only the file this committer staged, then ask whether the directory can go by attempting a non-recursive delete: it removes the directory when nothing is staged there any more, and refuses while a concurrent writer still has files in it. Removing it stays best-effort — a writer that stages here next recreates it — so a failure is logged at debug and the commit is unaffected.

Scope

paimon-common only, and in practice this is a format-table path: the sole production caller of FileIO.newTwoPhaseOutputStream is FormatTableSingleFileWriter, so managed tables never construct this stream. OSSFileIO, S3FileIO and JindoFileIO override it with multipart upload and do not stage under _temporary at all.

This is one of three PRs splitting up the original change; the other two are the format-table read path (#8861) and the INSERT OVERWRITE delete path, both in paimon-core and both independent of this one.

Tests

RenamingTwoPhaseOutputStreamTest:

  • testCleanKeepsTheSharedStagingDirectory — a foreign file staged in the same _temporary survives clean(); fails against the previous recursive delete
  • testCleanRemovesTheStagingDirectoryOnceEmpty — nothing else staged, so the directory does go, and a caller listing the target directory does not trip over a leftover _temporary

@JingsongLi JingsongLi left a comment

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.

When clean() non-recursively deletes the empty _temporary directory, another writer may have just completed mkdirs(_temporary) but not yet created the temporary file; in this case, the directory will be deleted, and subsequently, when that writer attempts to open the temporary file, it will immediately throw an IOException. It is recommended that clean() delete only its own tempPath and not delete the shared _temporary directory.

@sundapeng

Copy link
Copy Markdown
Member Author

Thanks for the review, you are right.

LocalFileIO.newOutputStream does mkdirs(parent) first and then opens the file, they are two steps. So an empty _temporary does not mean nobody is using it, another writer may be in the middle of these two steps.

I updated the PR. Now clean() only deletes its own tempPath, and does not touch the _temporary directory anymore. The directory will stay there, and the writers who use it can remove it. Tests are updated too.

…ge there

RenamingTwoPhaseOutputStream stages into <target-dir>/_temporary/.tmp.<uuid>,
which is the same directory Hadoop's FileOutputCommitter and everything built on
it use. clean() removed that directory recursively, so a Paimon commit deleted
whatever another job had staged there and that job failed to commit its own
files.

Delete only the file this committer staged, then ask whether the directory can
go by attempting a non-recursive delete: it removes the directory when nothing
is staged there any more and refuses while a concurrent writer still has files
in it. Removing it stays best-effort - a writer that stages here next recreates
it - so a failure is logged at debug and the commit is unaffected.
Removing the directory when it looks empty is not safe: a writer that has just
created '_temporary' has not staged its file in it yet, and deleting it from
under that writer makes its open fail. Empty is not the same as unused.

Delete only the file this committer staged. The directory outlives the commit,
which is what every other writer of the same location already assumes.
…nterface

The test that named the directory it protects could not see it disappear:
FileIO.listStatus answers with no entries for a directory that is gone as much
as for one that is empty, so asserting the listing is empty passed either way.
Deleting the empty shared directory - the very race this change is about - left
the whole suite green. Assert exists() instead, in both new tests.

Committer.clean had no javadoc at all, so the rule this change establishes lived
only in one implementation's comment: the next implementer of an @public
extension point could reintroduce the same deletion and read nothing that says
otherwise. State it on the interface, and say when clean runs as opposed to
discard.

Also name the two tests after what separates them, an empty staging directory
against one that holds another writer's file.
…ssed-read test

The test picks the first entry of the table directory and gzips it, excluding
only '.'-prefixed names. Now that a writer leaves its '_temporary' behind, that
entry can be the staging directory, and the test fails opening a directory as a
file. Exclude what the reader excludes.
@sundapeng
sundapeng force-pushed the fix/format-table-staging-dir-clean branch from ac536d3 to 4c0c1fe Compare July 29, 2026 09:21
Sign up for free to 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.

2 participants