Skip to content

fix(ext4): stop publishing newly written and in-size preallocated blocks as unwritten - #2569

Closed
sparkzky wants to merge 1 commit into
DragonOS-Community:masterfrom
sparkzky:fix/ext4-unwritten-extent-write
Closed

sparkzky wants to merge 1 commit into
DragonOS-Community:masterfrom
sparkzky:fix/ext4-unwritten-extent-write

Conversation

@sparkzky

Copy link
Copy Markdown
Member

Problem

The write path (transaction_extent_query_or_create) and fallocate's
preallocate_range_batch marked every freshly allocated block as an unwritten
extent, but this backend has no unwritten → written conversion. Consequently the first
append after such an allocation is rejected by extent_right_spine_projection
(extent.rs: "last extent is unwritten" → ENOTSUP), which surfaces from write(2)
as EOPNOTSUPP ("operation not supported").

Real-world impact (occurs on DragonOS with OCManager containers):

  • sqlite: posix_fallocate() followed by pwrite() fails with
    SQLITE_IOERR_WRITE and system errno 95; every later insert keeps failing;
  • JVM / kafka startup fails when it preallocates a file and then writes into it.

Copying the affected file to a new inode "fixes" it, because the copy gets written
extents — consistent with the report.

Fix

Both allocation sites already zero-fill the block before publishing the extent
(allocate_initialized_data / initialize_allocated_range), so publishing them as
written is safe:

Path Change
write path (transaction_extent_query_or_create) publish a normal written extent; the caller writes its data immediately
fallocate (preallocate_range_batch) new preallocate_range_batch_initialized(…, initialized_upto); blocks inside the range that becomes file data (mode 0: up to the new EOF) are written, blocks beyond EOF (FALLOC_FL_KEEP_SIZE) stay unwritten so e2fsck still sees a consistent inode size
ext4/inode.rs pass end for mode 0 and the current size for KEEP_SIZE

The plain preallocate_range_batch() keeps its previous all-unwritten semantics.

Verification

  • make kernel passes; cargo +nightly-2026-02-24 fmt --all -- --check clean.
  • DragonOS + 16 OCManager containers: three consecutive assets/add through
    api → asset-manager succeed (ids 1, 2, 3); a duplicate uuid returns
    UNIQUE constraint failed: assets.uuid; disk I/O error: operation not supported
    no longer appears.

Notes

  • The non-journaled allocator (fallocate/direct.rs, Edit::Allocate) still publishes
    unwritten extents; it is not used by the journaled mount that was tested and is left
    unchanged to keep this fix reviewable.

…cks as unwritten

The write path (transaction_extent_query_or_create) and fallocate's
preallocate_range_batch always marked freshly allocated blocks unwritten.
This backend has no unwritten-to-written conversion: the first append after
such an allocation is rejected by extent_right_spine_projection with
ENOTSUP, which surfaces as EOPNOTSUPP from write(2).

Applications with preallocation hit this immediately:
- sqlite's posix_fallocate + pwrite fails with SQLITE_IOERR_WRITE and
  system errno 95 ("operation not supported");
- JVM / kafka startup fails when it preallocates and then writes.

Both allocation sites already zero-fill the block (allocate_initialized_data
/ initialize_allocated_range), so publishing them as written is safe:

- write path: the block gets the caller's data right after allocation, so
  publish a normal written extent;
- fallocate: add preallocate_range_batch_initialized(..., initialized_upto)
  and mark blocks written only when they fall inside the range that becomes
  file data (grow: up to the new EOF); blocks beyond EOF (FALLOC_FL_KEEP_SIZE)
  stay unwritten so e2fsck still sees a consistent inode size.

The kernel call site passes end for mode 0 and the current size for
KEEP_SIZE. The plain preallocate_range_batch keeps its previous
all-unwritten semantics.

Verified on DragonOS with 16 OCManager containers: three consecutive
asset inserts through api -> asset-manager succeed (ids 1,2,3), a duplicate
uuid returns the UNIQUE constraint, and "operation not supported" no longer
appears.

Signed-off-by: sparkzky <sparkhhhhhhhhhh@outlook.com>

Copy link
Copy Markdown
Member

Reviewed against current master and Linux 6.6.139, including an independent subagent review. I am skipping this PR rather than merging it.

The original application symptom deserves a fix, but the stated premise that this backend has no unwritten-to-written conversion is no longer true: write_closed in another_ext4/src/ext4/low_level.rs prepares the extent splice, writes and flushes the payload, then commits the written extent; the direct backend also has direct_publish_written_block_locked. The buffered allocation path already falls back to eager allocation when an unwritten tail cannot use the append pool.

Publishing initialized extents during prepare_buffered_write, before the page-cache write and durable size update, weakens the crash/error invariant and can leave initialized mappings beyond durable EOF. The fallocate change likewise publishes written blocks before the separately committed size update. Linux instead keeps preallocations unwritten and converts them when data is written.

There is also concrete CI regression evidence: Ext4DirectRead.UnwrittenExtentUsesZeroReadSemantics now gets -1 instead of 37 for the unaligned zero read after fallocate. This should not be hidden by changing the test. The unrelated LoongArch CI failure is the heap-plan constant assertion and is covered separately by #2562.

No changes have been pushed and this PR has not been merged. If the application failure can still be reproduced on current master, the follow-up should target that remaining write/conversion path and preserve ordered payload, extent and size publication.

@github-actions github-actions Bot added Bug fix A bug is fixed in this pull request test Unitest/User space test labels Oct 26, 2026
@sparkzky

Copy link
Copy Markdown
Member Author

Agreed, closing. We independently verified this on current master (45754bdc, OCManager disk, QEMU snapshot): fallocate(fd, 0, 0, 64K) inside a container returns EOPNOTSUPP (overlayfs has no fallocate), yet the following pwrite calls at offsets 0/4096/61440 all succeed, and a sqlite (mattn/go-sqlite3) run does 5 inserts in WAL mode without errors. So the unwritten-tail poisoning we saw does not reproduce on master, and the change would only weaken the payload/extent/size ordering. The observed failure stays on the port branch and will be re-diagnosed after rebasing; if it survives that, it will come back as a focused fix that keeps the ordered publication.

@sparkzky sparkzky closed this Oct 26, 2026
@sparkzky sparkzky reopened this Oct 26, 2026
@sparkzky

Copy link
Copy Markdown
Member Author

Re-examined along the lines of your review; the patch as written is wrong and I am not defending it. What we found on our side is a port regression, not a master gap:

  • Current master already handles this: kernel/src/filesystem/ext4/inode.rs maps Err(ENOTSUP) from create_delalloc_extent_node_pool_authorized() to return Ok(None) (fall back to eager mapping), and write_closed / direct_publish_written_block_locked convert on write.
  • Our port branch lacks exactly that arm — it has Err(error) => return Err(SystemError::from(error).into()). That single missing fallback is the EOPNOTSUPP we chased: pool construction refuses the unwritten tail at extent.rs:636, the error escapes to userspace, and the writeback records it.
  • Measured on master (45754bdc) in a container: fallocate(fd, 0, 0, 64K) succeeds on an ext4 bind mount, then pwrite at 0 / 4096 / 61440 all succeed; sqlite does 5 inserts in WAL mode. No failure.

So the correct change is in our branch (add the missing ENOTSUP fallback), which also removes the need for this PR.

The only remaining upstream candidate I can see is submit-time RetryableNotPublished(ENOTSUP) (a pool built while the tail was clean, then fallocate introduces an unwritten tail). Retiring the pool there needs the DelallocAppendMapperAuthority plumbing, and I will not send it without a reproducible base.

Please tell me which you prefer: (a) I close this PR and the fallback stays in our port branch, or (b) you point me at a base (e.g. the #2560/#2562 chain) where the submit-time path still fails, and I implement that fallback there with a test.

@sparkzky

Copy link
Copy Markdown
Member Author

Concrete evidence for the direction above — verified on our port branch, without the ext4 change this PR proposes:

  • reverted the ext4 patch;
  • added only the upstream fallback that our branch was missing (Err(ENOTSUP) => return Ok(None) from create_delalloc_extent_node_pool_authorized, exactly as master has it);
  • booted that kernel with the 16-container OCManager stack: 16/16 containers Up, and three consecutive assets/add through api → asset-manager all return code:OK (ids 1, 2, 3), no operation not supported.

So the application failure is fully fixed by the upstream fallback alone; this PR's change is not needed for it.

Unless you prefer otherwise I will close this PR. If you would still like an upstream change, the only candidate I see is the submit-time RetryableNotPublished(ENOTSUP) (pool built while the tail was clean, then fallocate introduces an unwritten tail) — I can implement that on the #2560/#2562 base with a test, preserving payload/extent/size ordering.

sparkzky added a commit to sparkzky/DragonOS that referenced this pull request Oct 26, 2026
…y#2568 v2, DragonOS-Community#2569 findings)

Signed-off-by: sparkzky <sparkhhhhhhhhhh@outlook.com>
@fslongjin fslongjin closed this Oct 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Bug fix A bug is fixed in this pull request test Unitest/User space test

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants