Skip to content

[History Server] Keep directory placeholders out of ListFiles results - #5089

Open
stantheman0128 wants to merge 2 commits into
ray-project:masterfrom
stantheman0128:test/storage-listfiles-placeholder-coverage
Open

[History Server] Keep directory placeholders out of ListFiles results#5089
stantheman0128 wants to merge 2 commits into
ray-project:masterfrom
stantheman0128:test/storage-listfiles-placeholder-coverage

Conversation

@stantheman0128

@stantheman0128 stantheman0128 commented Aug 5, 2026

Copy link
Copy Markdown

Why are these changes needed?

ListFiles reports two kinds of entries, and its callers rely on telling them apart: a plain name is a file, a name ending in / is a subdirectory. ServerHandler.listFilesRecursive recurses into an entry only when it ends in a slash, and _getNodeLogs passes everything else to the log categorizer.

The s3 and aliyunoss backends break that rule. CreateDirectory writes a trailing-slash placeholder object for the directory itself, and an object listing whose prefix is that directory returns the placeholder as one of its keys. path.Base then strips the slash, so the directory reports itself as a file one level down. Feeding listFilesRecursive exactly what the pre-fix s3 backend returns for a node log directory gives:

listFilesRecursive  => [raylet.out events/events events/event_GCS.log]
categorizeLogFiles  => map[internal:[events event_GCS.log]]

That phantom events entry reaches the user through the ** glob log listing and the internal log category, and fetching it returns nothing. The event readers happen to be immune, because isValidEventFile and the event_ prefix check reject the name; ipToNodeId only wastes a lookup on it.

The same placeholder can also be picked up by the GetContent fallback, which matches on path.Base: a marker for a directory named x base-matches a request for a file named x, and the fallback then serves the empty marker instead of continuing the scan.

The gcs backend already filters these out, with a comment saying that is what it is doing:

// Exclude the placeholder object if it exists for the directory itself.
if attrs.Name != "" && !strings.HasSuffix(attrs.Name, "/") {

So the fix skips trailing-slash keys in the Contents loop of the s3 and aliyunoss _listFiles, and in both listing loops of the azureblob listBlobs. For azureblob that is defensive rather than a bug fix: its CreateDirectory deliberately writes nothing, because a marker blob shows up as <no name> in Azure Storage Explorer, so this codebase never creates one there. A marker left by another tool would still reach the callers, and after this change all four readers answer the same contract.

On the test side, gcs_handler_test.go has covered List, ListFiles, CreateDirectory and WriteFile all along. The other three backends had none of that, and aliyunoss had no test that constructed a handler at all. This PR adds those four methods per backend, driving each SDK against an httptest server so the assertions land on the requests that reach the wire, the same approach as the GetContent tests in #5075.

Related issue number

None. Found while covering GetContent for #5075.

Depends on #5075: this branch is stacked on it and reuses the test server helpers it introduces, so the diff here includes that PR's commit until it merges.

Labels

  • If this PR has user-facing changes that require documentation updates at release time, I have added the doc-updates-required label.
  • If this PR contains breaking changes, I have added the breaking-change label.

Checks

  • I've made sure the tests are passing.
  • Testing Strategy
    • Unit tests
    • Manual tests

Manual test instructions

Against a live MinIO, the same quay.io/minio/minio image config/minio.yaml deploys, using the in-tree CreateDirectory and WriteFile to lay down a node's events directory and then listing it:

docker run -d -p 9412:9000 quay.io/minio/minio:latest server /data
raw ListObjectsV2  prefix=<root>/<cluster>/session_1/logs/node123/events/  delimiter=/
  Contents:        ".../logs/node123/events/"                  <- the directory's own key
  Contents:        ".../logs/node123/events/event_GCS.log"
  Contents:        ".../logs/node123/events/event_RAYLET.log"
  CommonPrefixes:  ".../logs/node123/events/old/"

ListFiles before the fix => [events event_GCS.log event_RAYLET.log old/]
ListFiles after the fix  => [event_GCS.log event_RAYLET.log old/]

So the placeholder really does come back in Contents rather than being rolled up into CommonPrefixes, and events really does reach the caller looking like a file.

Evidence

Every new test was mutation tested. Twenty-three mutants, all killed:

Mutant Test that caught it
s3 / aliyunoss / azureblob keep the placeholder in the listing TestListFilesSeparatesFilesFromDirectories (per backend)
ListFiles prefix drops the root dir (x3) same
ListFiles asks for the listing without the / delimiter (x3) same
the listing always sends an empty delimiter (s3, aliyunoss) same
_listFiles ignores CommonPrefixes (s3) same
List prefix drops the root dir (x3) TestListReadsClusterMetadataUnderRootDir (per backend)
CreateDirectory omits the trailing slash (s3, aliyunoss) TestCreateDirectoryWritesPlaceholderWhenMissing, TestCreateDirectoryLeavesExistingDirectoryAlone
CreateDirectory ignores the existence check (s3) TestCreateDirectoryLeavesExistingDirectoryAlone
azureblob CreateDirectory starts writing a marker TestCreateDirectoryWritesNothing
WriteFile writes to path.Base of the key (x3) TestWriteFileUploadsBodyToGivenKey / ...Blob
ClusterInfoList sorts oldest first all three TestListReadsClusterMetadataUnderRootDir
  • go test ./pkg/... on Windows: green, except the pre-existing failure in pkg/utils (TestGetSessionDir_FatalError_FailsFast), which fails the same way on a clean master checkout and passes on Linux.
  • go test -race ./pkg/... -parallel 4, which is what make test runs in CI, on Linux: green, and A/B identical to a clean master checkout.
  • go vet ./...: clean.

What was not tested

  • The unit tests drive the real SDKs against an httptest stub, so on their own they pin down the requests the code sends and how it reads a response of a given shape. The MinIO run above is what backs the premise for s3. For Aliyun OSS and Azure Blob I am relying on API compatibility rather than a run against those services, and the MinIO check was a manual one, not something these tests reproduce.
  • The historyserver e2e suite needs a Kubernetes cluster and was not run locally.
  • No test exercises the collector-to-history-server round trip. The link comes from reading the code: logcollector.Writer.CreateDirectory and eventcollector write the marker on the same paths that ServerHandler and the event readers later list.
  • The filter added to the flat-listing branch of listBlobs is unreachable today, since both callers pass "/". It is there so the two branches agree, but no test covers it.
  • On an Azure account with hierarchical namespace enabled, directory entries come back without a trailing slash, so this filter would not catch them.
  • MaxKeys/MaxResults and the pagination boundary (NextContinuationToken, NextMarker) are not pinned by these tests.

…ob backends

Neither backend had any test touching GetContent. s3_test.go and
azureblob_test.go held only TestTrim and TestWalk, which assert nothing
and never construct a handler, so the object path that GetContent builds
from rootDir, cluster prefix and file name was unverified on both.

That path is easy to get wrong: the aliyunoss backend shipped without
the root dir in it (ray-project#4820), and the same mistake in either of these
would break every log fetch on a deployment that configures a root dir.

Add two tests per backend, driving the real SDK against an httptest
server so the assertion is on the path that reaches the wire rather than
on a helper's return value. s3 uses path style addressing, which the
MinIO support already relies on. The second test in each pair covers the
list-and-retry fallback, whose listing prefix has to be rooted as well.

No production code changes.
ListFiles reports two kinds of entries: files, plain, and subdirectories,
with a trailing slash. Callers depend on that distinction. ServerHandler
.listFilesRecursive recurses into an entry only when it ends in a slash,
and _getNodeLogs hands everything else to the log categorizer.

The s3 and aliyunoss backends break the distinction. CreateDirectory
writes a trailing-slash placeholder object for the directory itself, and
a listing whose prefix is that directory returns that object as a key.
path.Base then drops the slash, so the directory reports itself as a file
one level down. Listing a node's events directory yields an entry named
"events" next to the real event files, and it reaches the user through
the log listing and the "internal" log category. The event readers happen
to be immune, since isValidEventFile and the event_ prefix check reject
the name; ipToNodeId only wastes a lookup on it.

The same placeholder can also be picked up by the GetContent fallback,
which matches on path.Base: a marker for a directory named x base-matches
a request for a file named x, and the fallback then serves the empty
marker instead of continuing the scan.

The gcs backend already filters these out, with a comment saying so. Skip
them in s3 and aliyunoss too, and in azureblob, which writes no
placeholder itself but is just as exposed to one written by another tool.

Cover the four methods that had no test on these three backends: List,
ListFiles, CreateDirectory and WriteFile, which gcs_handler_test.go has
covered for gcs all along. As with the GetContent tests, the SDK runs
against an httptest server so the assertions land on the requests that
reach the wire. aliyunoss had no test that constructed a handler at all.
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.

1 participant