Skip to content

feat(fs): add file-system.max-dir-entries write guard (ENOSPC above limit) - #4977

Open
chaitanyapantheor wants to merge 3 commits into
GoogleCloudPlatform:masterfrom
chaitanyapantheor:feat/max-dir-entries-write-guard
Open

feat(fs): add file-system.max-dir-entries write guard (ENOSPC above limit)#4977
chaitanyapantheor wants to merge 3 commits into
GoogleCloudPlatform:masterfrom
chaitanyapantheor:feat/max-dir-entries-write-guard

Conversation

@chaitanyapantheor

Copy link
Copy Markdown

Description

Adds a file-system.max-dir-entries flag / config field. When it is greater than zero, creating a new file in a directory that already contains at least this many entries is rejected with ENOSPC and a WARNING is logged. 0 (the default) keeps the guard disabled, so behavior is unchanged unless a user opts in.

This implements the write-guard requested in #4696 — the corruption-prevention piece. Read-path mitigations (kernel list cache TTL, larger stat cache) reduce listing cost but do not stop a directory from growing further into the size range where listing degrades and metadata state can corrupt. Returning ENOSPC ("no space left on device") gives applications and scripts a standard, actionable signal at the source.

Implementation notes:

  • The entry count is bounded: it uses ReadDescendants(ctx, limit), which stops once it has seen the limit — mirroring the existing file-system.rename-dir-limit idiom. A directory comfortably below the limit only pays to list its own (few) entries; the cost is only incurred for directories at/near the limit, which are exactly the ones to protect.
  • The guard is enforced at the CreateFile op (a single choke point covering both the empty-object and streaming/local create paths).
  • Directories that don't support descendant listing (e.g. the multi-bucket mount root, which returns ENOSYS) are left to the normal create path.

Scope: this PR covers requested feature #1 (write guard) only. The warning-threshold log (#2) is #4736; streaming readdir (#3) and documented limits (#4) are tracked in #4976.

Link to the issue in case of a bug fix.

Implements part of #4696. Remaining-scope tracking issue: #4976.

Testing details

  1. Manual - Not run locally (FUSE mount is Linux-only; dev machine is macOS).
  2. Unit tests - go test ./cfg passes with the new flag. Added internal/fs/max_dir_entries_test.go (fsTest-harness behavioral test: creates into a directory until a create is rejected, asserts the rejection is ENOSPC and occurs after no more than limit successes; plus a below-limit success case). Verified it compiles under GOOS=linux go test -c ./internal/fs/.
  3. Integration tests - The internal/fs FUSE-backed suite runs in the project's Linux CI.

Any backward incompatible change? If so, please explain.

No. The guard is off by default (max-dir-entries: 0); existing behavior is unchanged unless a user sets a positive limit.

Add a `file-system.max-dir-entries` flag/config field. When it is greater
than zero, creating a new file in a directory that already contains at
least this many entries is rejected with ENOSPC and a warning is logged.
0 (the default) keeps the guard disabled.

This addresses the write-guard request in GoogleCloudPlatform#4696 (the corruption-prevention
piece): read-path mitigations reduce listing cost but do not stop a
directory from growing further into the size range that degrades listing
performance and risks metadata corruption. Returning ENOSPC gives
applications a standard, actionable "no space left on device" signal.

The count is bounded via ReadDescendants (it stops once it has seen the
limit), mirroring the existing rename-dir-limit idiom, so directories
comfortably below the limit only pay to list their own entries.

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request introduces a max-dir-entries configuration limit to prevent directories from growing too large, returning ENOSPC when the limit is exceeded. Feedback on the implementation highlights a correctness bug where recursive rather than flat counting is used, and a performance bottleneck caused by holding an exclusive lock during GCS network calls. Additionally, the reviewer recommends extending this guard to other entry-creating operations (such as MkDir, CreateSymlink, MkNode, and Rename) and expanding test coverage to verify these scenarios.

Comment thread internal/fs/fs.go Outdated
Comment thread internal/fs/fs.go
Comment thread internal/fs/max_dir_entries_test.go Outdated
- Count direct entries (non-recursive) instead of ReadDescendants, so a
  parent with few direct entries is not blocked because its subdirectories
  collectively hold many files. Adds a bounded, lock-free CountDirEntriesUpTo
  (Delimiter "/") to DirInode.
- Perform the GCS listing without holding the parent inode's exclusive lock,
  so entry creation is no longer serialized behind the network call.
- Extend the guard to MkDir, MkNode, CreateSymlink, and cross-directory
  Rename so the limit cannot be bypassed.
- Expand tests: direct-vs-recursive counting, and MkDir/CreateSymlink
  blocking once a directory is full.
@chaitanyapantheor

Copy link
Copy Markdown
Author

Thanks for the review — addressed all three findings in the latest commit:

1. Recursive vs. flat counting + lock contention. Replaced ReadDescendants with a new DirInode.CountDirEntriesUpTo(ctx, limit) that lists direct entries only (Delimiter: "/", via listObjectsAndBuildCores) and stops once the count reaches the limit. It performs the GCS listing without holding the parent inode's lock (mirroring readObjectsUnlocked) and does not refresh caches, so a parent with few direct entries is no longer blocked by a full subdirectory, and file creation is no longer serialized behind the network call.

2. Completeness. The guard is now enforced in MkDir, MkNode, CreateSymlink, and Rename (destination parent, only for cross-directory moves — in-place renames don't change the destination's count), in addition to CreateFile.

3. Test coverage. Added:

  • TestDirectEntryCountIsNotRecursive — a parent with several subdirectories each filled just under the limit (recursive count far above the limit) still accepts a direct create; this fails under recursive counting.
  • TestMkDirAndSymlinkBlockedWhenDirIsFull — once a directory is full, MkDir and CreateSymlink are rejected with ENOSPC; the attempted entries are intentionally not cleaned up.

go test ./cfg passes; internal/fs builds and the test binary compiles under GOOS=linux (the FUSE suite runs in CI).

…F1008

golangci-lint (staticcheck QF1008) flagged t.fsTest.SetUpTestSuite/
TearDownTestSuite/TearDown as redundant embedded-field selectors. Call the
promoted methods directly (t.SetUpTestSuite() etc.) to clear the Lint CI
failure. No behavior change.
@chaitanyapantheor

Copy link
Copy Markdown
Author

Thanks for the review — all three points are addressed in the current implementation (68167b2), and I've just pushed 8c2a34f to fix the failing Lint.

1. Recursive counting + lock contention (both fixed). The count no longer uses ReadDescendants (recursive, Delimiter: ""). It now uses a new DirInode.CountDirEntriesUpTo, which lists flat through listObjectsAndBuildCores (hardcoded Delimiter: "/"), so only direct entries are counted — a parent with a few subdirs that themselves hold many files is no longer blocked. It's bounded (stops as soon as the count reaches the limit) and performs the GCS list without holding the inode lock (mirroring readObjectsUnlocked), so concurrent operations on the directory are not serialized behind the network call.

2. Completeness (fixed). The guard is enforced at every entry-creating op, not just CreateFile: MkDir, MkNode, CreateSymlink, and cross-directory Rename (destination parent; in-place renames are skipped since they don't change the destination's count).

3. Test coverage (added). TestDirectEntryCountIsNotRecursive builds subdirectories whose recursive descendant count far exceeds the limit and asserts a direct create in the parent still succeeds (this would fail under recursive counting). TestMkDirAndSymlinkBlockedWhenDirIsFull asserts MkDir and CreateSymlink into a full directory are rejected with ENOSPC, and deliberately does not clean up those artifacts so a leftover entry is evidence of failure.

CI. Fixed the Lint failure (staticcheck QF1008 — redundant embedded fsTest selectors in the test). Verified locally: gofmt clean, go test ./cfg passes, GOOS=linux go vet ./internal/fs/ ./internal/fs/inode/ and GOOS=linux go test -c ./internal/fs both compile, and golangci-lint reports no issues on the changed file. (zizmor-scan is the Actions-workflow security scanner and is unrelated — this PR touches no workflow files.)

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