Immediate Write Mirror - High Level Design: Difference between revisions

From Lustre Wiki
Jump to navigation Jump to search
(Create migration draft from committed local IWM design 57398ab; figures 21b52e0; reviewed wiki conversion)
 
(Update to current design (local commit feb9fd3): October decisions, normative rewrite of all sections, open items summary and decision log)
 
(2 intermediate revisions by the same user not shown)
Line 1: Line 1:
'''Migration draft.''' Copied from the committed local design in <code>paf0186/lustre_design_docs</code> (<code>projects/immediate-write-mirroring/design/immediate_write_mirroring_design.md</code>; source commit <code>57398ab674208365679706f5e5adcba48ea67470</code>), with diagrams committed in <code>21b52e0</code>. The original Confluence page is <code>373565003</code> on <code>wiki.whamcloud.com</code>. Historical discussions and server revisions are archived separately; they are not recreated as native wiki history here.
'''Migration draft.''' Copied from the committed local design in <code>paf0186/lustre_design_docs</code> (<code>projects/immediate-write-mirroring/design/immediate_write_mirroring_design.md</code>; source commit <code>feb9fd325f72f2b2f1df12e7355586514822a739</code>), with diagrams committed in <code>21b52e0</code>. The original Confluence page is <code>373565003</code> on <code>wiki.whamcloud.com</code>. Historical discussions and server revisions are archived separately; they are not recreated as native wiki history here. Source-code references are given as plain-text paths and line numbers at Lustre commit <code>47638add78158a74441ed3e56392f5a720cef764</code> (Linux references at <code>v6.12</code>) rather than links.


__TOC__
__TOC__
Line 15: Line 15:
Today, we have manual synchronization: after a write, the lfs mirror resync command must be run to synchronize the stale mirrors with the primary mirror. This command copies data from the synced mirror to the stale mirrors and removes the stale flag from successfully copied mirrors.
Today, we have manual synchronization: after a write, the lfs mirror resync command must be run to synchronize the stale mirrors with the primary mirror. This command copies data from the synced mirror to the stale mirrors and removes the stale flag from successfully copied mirrors.


Layout state and staleness is managed through a careful series of layout state changes which are described File-level replication state machine in [[#detailed-design|Core Design Concept]] in this document.
Layout state and staleness are managed through a careful series of layout state changes; see the file-level replication state machine in [[#detailed-design|Core Design Concept]] in this document.


This delayed write approach was implemented in the first phase of FLR to avoid the complexity of maintaining consistency across multiple mirrors during concurrent writes. By updating only one mirror during writes and marking others as stale, the system maintains a consistent view of the file data, at the cost of requiring explicit synchronization after writes complete.
This delayed write approach was implemented in the first phase of FLR to avoid the complexity of maintaining consistency across multiple mirrors during concurrent writes. By updating only one mirror during writes and marking others as stale, the system maintains a consistent view of the file data, at the cost of requiring explicit synchronization after writes complete.


The current implementation does not provide immediate redundancy, since only on a single mirror is up to date until an explicit resync operation is performed. This approach enables things like hot pools synchronization, but the lack of immediate write redundancy severely limits the use cases.
The current implementation does not provide immediate redundancy, since only a single mirror is up to date until an explicit resync operation is performed. This approach enables things like hot pools synchronization, but the lack of immediate write redundancy severely limits the use cases.


<span id="immediate-mirroring"></span>
<span id="immediate-mirroring"></span>
Line 26: Line 26:
We have a requirement to do immediate mirroring on Lustre files, where all writes (and related ops) are replicated to multiple mirrors immediately. The infrastructure created for this will also be used for immediate erasure coding.
We have a requirement to do immediate mirroring on Lustre files, where all writes (and related ops) are replicated to multiple mirrors immediately. The infrastructure created for this will also be used for immediate erasure coding.


Whether a mirror participates in immediate write mirroring is controlled by a per-mirror <code>IMMEDIATE</code> flag in the composite layout (<code>lcme_flags</code>), alongside existing flags like <code>STALE</code> and <code>PREFER</code>. The flag is set at layout creation time (e.g., <code>lfs mirror create</code> or <code>lfs setstripe</code>) or changed later through the conditional promotion/demotion operations described in [[#combining-immediate-and-non-immediate-mirrors|§4.2]]. Mirrors without the flag are ordinary FLR mirrors — written lazily via resync — and do not participate in write duplication or AW lock epochs. Immediate and non-immediate mirrors can coexist on the same file.
Whether a mirror takes part in immediate write mirroring is controlled by a per-mirror <code>IMMEDIATE</code> flag in the composite layout (<code>lcme_flags</code>), alongside existing flags like <code>STALE</code> and <code>PREFER</code>. Mirrors without the flag are ordinary FLR mirrors, written lazily via resync. Immediate and non-immediate mirrors can coexist on the same file.


In the IWM write, fan-out, and epoch descriptions below, “all mirrors” means the eligible immediate participants, including the immediate primary. Non-immediate copies are not brought into the fan-out merely because they are clean, and their staling follows ordinary FLR rules when a modifying operation omits them. Changing the immediate participant set requires an epoch boundary.
The '''participants''' of a write epoch are the IMMEDIATE mirrors that are not STALE when the epoch opens, primary included. Writes and other modifying operations go to every participant. Non-immediate mirrors are not written; when a modifying operation omits a clean non-immediate mirror, its affected components are marked STALE under ordinary FLR rules. Changing which mirrors are IMMEDIATE requires an epoch boundary.
 
<code>lfs mirror create --immediate</code> marks every mirror the command creates IMMEDIATE, on all of its components; explicit per-mirror component flags allow mixed immediate/non-immediate layouts. Existing files change membership only through the conditional promotion and demotion operations in [[#combining-immediate-and-non-immediate-mirrors|§4.2]]. The shorthand implies no minimum number of copies (implementation: <code>lustre-design-docs-iwm.2.3</code>).


The goal is to have redundancy immediately, but during writes only a single mirror (the primary) is available for reads — see [[#primary-mirror-selection-and-read-visibility|Primary Mirror Selection and Read Visibility]] for the rationale.
The goal is to have redundancy immediately, but during writes only a single mirror (the primary) is available for reads — see [[#primary-mirror-selection-and-read-visibility|Primary Mirror Selection and Read Visibility]] for the rationale.


Non-IWM clients cannot participate in an active epoch. Before giving such a client a usable layout, the MDS closes any active epoch. After that close, an access/read request receives the completed RDONLY layout with healthy mirrors still clean; a write intent invokes ordinary delayed-write FLR and stales affected secondary components. Existing legacy WRITE_PENDING access outside an IWM epoch follows normal FLR rules. Starting another IWM epoch requires revoking legacy layouts and draining or fencing outstanding legacy writes first. See [[#conflicting-operations-during-write-epochs-resync-old-clients|Old-Client Compatibility]].
Compatibility is capability-based. Clients we can modify (2.18/master and 2.15) treat unknown mirror-state flags as STALE for mirror selection and advertise this with a separate connect capability, so they skip INFLIGHT mirrors even without full IWM support. The MDT refuses to open files with IMMEDIATE mirrors for clients without that capability, whether or not an epoch is active. Capability-bearing clients without full IWM support can read the primary; their writes are denied by default, or, with an optional setting, use ordinary FLR write intents that mark every non-primary mirror STALE and write the primary only. See [[#conflicting-operations-during-write-epochs-resync-old-clients|Old-Client Compatibility]].
 
'''V1 scope.''' Data-on-MDT is excluded: layouts combining DoM with IMMEDIATE mirrors are rejected, including on promotion; ordinary DoM is unchanged. NFS re-export, PCC and HSM are supported under the normal layout/AW admission and drain rules, and every IO and lifecycle path they use must be covered (<code>lustre-design-docs-od5.49.14</code>; mmap in <code>lustre-design-docs-od5.36</code>).


The core idea of the design is this:
The core idea of the design is this:


To write an IWM file, the client first acquires the layout lock (sending a write intent to transition the layout from RDONLY to WRITE_PENDING if needed), then takes an Active Writer (AW) lock — a CW lock on a separate IBITS bit (ACTIVE_WRITERS) on the same per-file resource. The layout lock and AW lock are independent: layout changes (component instantiation, SEL extension) revoke LAYOUT but leave ACTIVE_WRITERS untouched, so the write epoch spans layout changes without interruption (see [[#aw-lock-vs-layout-lock-interaction|AW Lock vs Layout Lock Interaction]]).
To write an IWM file, the client first obtains a valid layout, then takes or references a cached Active Writer (AW) lock at the top of the modifying IO, before it can dirty data or obtain or use PW write authority. AW is a CW lock on a separate IBITS bit (ACTIVE_WRITERS) on the same per-file resource. The first AW request opens an epoch: the MDT selects the primary by the usual policy, moves the file to WRITE_PENDING, sets INFLIGHT on the other participants, and makes the active-epoch record durable before write authority becomes usable. Later AW requests join the open epoch. The layout hold is soft and can be revoked without waiting for a flush. The server always takes LAYOUT before ACTIVE_WRITERS, with EX on both, to close an epoch or for any operation needing both; ordinary layout changes within an epoch (component instantiation, SEL extension) revoke LAYOUT alone and leave AW untouched. See [[#aw-lock-vs-layout-lock-interaction|AW Lock vs Layout Lock Interaction]].


The client sends all writes to all online mirrors in parallel. A single primary mirror will be selected for reads during this time, since we cannot guarantee all mirrors are identical during writes. This mirror is the &quot;write leader&quot;, also used for write ordering (locks are taken on this mirror first). All other mirrors are marked INFLIGHT during writes — see [[#mirror-states-stale-and-inflight-flags|Mirror States]] for the full flag model.
The client sends all writes to all participating mirrors in parallel. A single primary mirror will be selected for reads during this time, since we cannot guarantee all mirrors are identical during writes. This mirror is the &quot;write leader&quot;, also used for write ordering (extent locks are taken on this mirror only). All other mirrors are marked INFLIGHT during writes — see [[#mirror-states-stale-and-inflight-flags|Mirror States]] for the full flag model.


Clients hold the AW lock until each participating mirror’s updates are committed to OST storage or recorded as failed, with outstanding IO safely resolved. Per-mirror errors are reported to the MDS via the cancellation LVB. On error (or for administrative operations), the MDS takes AW in EX mode, forcing writers to flush or resolve failures and release, then clears INFLIGHT from clean mirrors and sets STALE on failed ones. V1 supports fast-fail and slow-fail secondary policies under this same release rule. See [[#write-operation-flow|Write Operation Flow]] and [[#active-writer-lock|Active Writer Lock]].
Clients hold AW until their admitted modifying IO has drained, the resulting dirty data has been flushed, and each participant’s updates are durable on OST storage or recorded as failed, with outstanding IO safely resolved. Per-mirror errors are reported to the MDS in the AW cancellation. On error, or for administrative operations, the MDS takes layout EX then AW EX, which forces writers through this release path; it then clears INFLIGHT from clean mirrors and sets STALE on failed ones. Secondary IO has three failure policies (wait indefinitely, finite slow-fail, finite fast-fail) that differ only in timing; administrative abandonment of a dead OST is a separate operation. See [[#fast-fail-and-secondary-mirror-failure-policy|Secondary Failure Policy]], [[#write-operation-flow|Write Operation Flow]] and [[#active-writer-lock|Active Writer Lock]].


Write ordering is enforced by requiring client-side LDLM extent locks on the primary mirror. This means DIO to IWM files must use client-side locks where today it operates locklessly — a performance regression for shared-file DIO workloads. Restoring lockless DIO is a high priority for future work; see [[#write-ordering-alternatives|Write Ordering Alternatives]].
Write ordering is enforced by requiring client-side LDLM extent locks on the primary mirror. This means DIO to IWM files must use client-side locks where today it operates locklessly — a performance regression for shared-file DIO workloads. Restoring lockless DIO is a high priority for future work; see [[#write-ordering-alternatives|Write Ordering Alternatives]]. Hybrid IO, which turns large buffered IO into DIO, is DIO for this purpose and takes the same client lock. Group locks are allowed unchanged: group-lock holders already get no write ordering between them, and with IWM that includes possible differences between mirrors for overlapping writes, which are not detected or marked STALE.


Client eviction, MDS failover, write leader failure, and other recovery scenarios are covered in [[#failure-handling|Failure Handling]].
Client eviction, MDS failover, write leader failure, and other recovery scenarios are covered in [[#failure-handling|Failure Handling]].
Line 65: Line 69:
== Write Operation Flow ==
== Write Operation Flow ==


Before starting a write (or other OST-modifying operation — see [[#other-modifying-operations|§2.3.2]]), the client acquires the layout lock and then the AW lock (see [[#aw-lock-vs-layout-lock-interaction|Lock Ordering]]). For buffered writes, AW must cover acquisition/use of the primary PW extent write lock, before data can be dirtied; it is not first acquired at writeback. Ordinary buffered <code>write()</code> may return after accepting data into the client cache, with normal aggregation and later BRW fan-out to the participating mirrors. The primary mirror remains readable; secondaries are INFLIGHT (see [[#mirror-states-stale-and-inflight-flags|Mirror States]]).
At the top of each write or other OST-modifying IO (see [[#other-modifying-operations|§2.3.2]]), the client obtains a valid layout first and then takes an AW reference, acquiring the lock or reusing a cached one (see [[#aw-lock-vs-layout-lock-interaction|Lock Ordering]]). This precedes primary PW acquisition/reuse and any dirtying; AW is not first acquired at writeback or BRW submission. Ordinary buffered <code>write()</code> may return after accepting data into the client cache, with normal aggregation and later BRW fan-out to the participating mirrors. The primary mirror remains readable; secondaries are INFLIGHT (see [[#mirror-states-stale-and-inflight-flags|Mirror States]]).


The client holds AW across this write-permission window and its resulting dirty data and IO. Before releasing AW, it must stop further dirtying under that authority, obtain committed or recorded-failed outcomes for every participating mirror, and safely resolve outstanding IO (see [[#lock-lifetime-and-commit-requirements|Lock Lifetime]]). It releases AW with the mirror-error summary (see [[#error-reporting-via-cancellation-lvb|Error Reporting]]). Application return, RPC completion, durable commit, AW release, and global epoch closure are distinct events; the agreed application contracts and remaining mechanism questions are recorded in [[#operation-completion-consistency-model|Operation Completion Consistency Model]].
The client holds AW across this write-permission window and its resulting dirty data and IO. Before releasing AW, it must stop further dirtying under that authority, obtain committed or recorded-failed outcomes for every participating mirror, and safely resolve outstanding IO (see [[#lock-lifetime-and-commit-requirements|Lock Lifetime]]). It releases AW with the mirror-error summary (see [[#error-reporting-via-cancellation-lvb|Error Reporting]]). Application return, RPC completion, durable commit, AW release, and global epoch closure are distinct events; the agreed application contracts and remaining mechanism questions are recorded in [[#operation-completion-consistency-model|Operation Completion Consistency Model]].
Line 80: Line 84:
The client will report errors on mirror 0 and mirror 1 to the MDS as part of AW lock cancellation (see [[#active-writer-lock|Active Writer Lock]] for the LVB structure). The MDS would then clear INFLIGHT from mirror 2 (it is now clean) and mark mirrors 0 and 1 as STALE (clearing INFLIGHT, adding STALE). Userspace can try to resync to these mirrors, and if this fails, will need to add new mirrors.
The client will report errors on mirror 0 and mirror 1 to the MDS as part of AW lock cancellation (see [[#active-writer-lock|Active Writer Lock]] for the LVB structure). The MDS would then clear INFLIGHT from mirror 2 (it is now clean) and mark mirrors 0 and 1 as STALE (clearing INFLIGHT, adding STALE). Userspace can try to resync to these mirrors, and if this fails, will need to add new mirrors.


If all mirrors fail writes during an IO, the file is degraded to its pre-IWM state — all mirrors are marked STALE on epoch close and the file requires resync. This is the same outcome as a total write failure in delayed-write FLR today.
If all mirrors fail writes during an IO and no successful eligible replacement exists, report the failures through the usual IO error and logging paths. On epoch close, retain the existing primary as the sole valid, read-eligible best-effort replica, clear INFLIGHT and mark every other mirror STALE. The primary may be incomplete; selecting it does not turn failed IO into success. Resync can use its current contents to restore redundancy, but cannot reconstruct missing updates. See the selected policy and review note in [[#primary-mirror-write-failure|§2.4.4]].


<span id="primary-mirror-selection-and-read-visibility"></span>
<span id="primary-mirror-selection-and-read-visibility"></span>
=== Primary Mirror Selection and Read Visibility ===
=== Primary Mirror Selection and Read Visibility ===


Only one mirror is readable during an IWM epoch: without MVCC (which Lustre’s storage backends lack), writes arriving at different mirrors at different times could expose inconsistent data to concurrent readers. The MDS marks secondary mirrors INFLIGHT at epoch open, and IWM-aware clients exclude them from reads. The primary mirror remains unflagged and readable. This also covers OST-authoritative metadata (notably file size) — IWM-aware clients query only the primary while secondaries are INFLIGHT. Non-IWM clients can ignore unknown component flags: their existing layout grants must be revoked before epoch entry, and any subsequent request for a usable layout closes the epoch before it is granted. See [[#conflicting-operations-during-write-epochs-resync-old-clients|Old-Client Compatibility]] and [[#mirror-states-stale-and-inflight-flags|Mirror States]]. However, this is not possible for DIO, which does not use LDLM locks on the client, but instead only uses them locally on each separate server.
The primary is the only readable participant during an epoch: without MVCC (which Lustre’s storage backends lack), writes reach different mirrors at different times, and concurrent readers could otherwise see inconsistent data. At epoch entry the MDT marks every other participant INFLIGHT. IWM clients, and patched non-IWM clients through their unknown-flags-as-STALE rule, exclude INFLIGHT mirrors from reads and from OST-derived metadata such as file size. The primary is never INFLIGHT. Clients without the safe-selection capability cannot open IMMEDIATE files at all ([[#conflicting-operations-during-write-epochs-resync-old-clients|§2.4.6]]). Reads already in flight at entry rely on the layout invalidation and revalidation that entry triggers. See [[#mirror-states-stale-and-inflight-flags|Mirror States]]. However, this is not possible for DIO, which does not use LDLM locks on the client, but instead only uses them locally on each separate server. ''(Superseded: v1 DIO to IWM files takes client-side extent locks on the primary; see §1.2.)''


<span id="active-writer-lock"></span>
<span id="active-writer-lock"></span>
== Active Writer Lock ==
== Active Writer Lock ==


This section covers the mechanics of the Active Writer (AW) lock: how it is used, what it guarantees, and how the MDS uses it to manage write epochs. References below to flushing or syncing before release include the failure resolution described here: successful mirrors must be durable, while terminally failed mirrors must be reported and their outstanding IO safely resolved.
This section covers the Active Writer (AW) lock: how it is used, what it guarantees, and how the MDS uses it to manage write epochs. Throughout, “flush before release” includes failure resolution: successful mirrors must be durable, and terminally failed mirrors must be reported with their outstanding IO safely resolved.


<span id="lock-semantics"></span>
<span id="lock-semantics"></span>
=== Lock Semantics ===
=== Lock Semantics ===


The AW lock is a CW (concurrent write) lock on a special MDS IBITs bit called ACTIVE_WRITERS. Multiple clients can hold it simultaneously. The MDS uses lock presence to determine whether any writers are active on a file. When the MDS needs exclusive access (epoch close, mirror replacement, resync), it requests the AW lock in EX mode, which forces all clients to flush and release.
The AW lock is a CW (concurrent write) lock on a dedicated MDS IBITS bit, ACTIVE_WRITERS. Many clients can hold it at once, and the MDS uses its presence to know whether a file has active writers. When the MDS needs exclusive access to both layout and write epoch (epoch close, mirror replacement, resync), it takes layout EX first and then AW EX, which forces every holder to drain admitted IO, flush and release.
 
Every distributed AW acquisition, whether it opens the epoch or joins an open one, durably records that client’s participation before its write authority becomes usable ([[#recovery-after-mds-failover|§2.2.5]]; cost in [[#error-reporting-via-cancellation-lvb|§2.2.3]]). Taking an IO reference to an already-cached AW grant acquires nothing new and writes nothing.
 
'''Epoch identity.''' An epoch is identified by the file plus the layout generation of the transition that opened it (RDONLY → WRITE_PENDING). Entry already bumps the generation, so the value is unique and increasing per file; the current generation cannot serve because it changes within an epoch. The MDT stores it in the active-epoch record and returns it with the AW grant; the client puts it in the cancellation report header; participation and error records carry it. Since only one epoch per file can be open at a time, the file alone identifies the open epoch on the MDT; the epoch ID matters for messages that outlive their epoch. The MDT discards, and logs, any cancellation report or replayed AW lock whose epoch ID is not the file’s open epoch. For example, an error report retried after an MDT restart, whose lock was cancelled locally and so not replayed, was already covered by the lost-participant rule when that epoch closed and must not be applied to a later one.


<span id="lock-lifetime-and-commit-requirements"></span>
<span id="lock-lifetime-and-commit-requirements"></span>
=== Lock Lifetime and Commit Requirements ===
=== Lock Lifetime and Commit Requirements ===


For buffered IO, the primary PW extent write lock defines the coverage window: AW must be held before that write authority can be used to dirty data, including reuse of a cached PW lock and mmap write permission. Dirty data belongs to that epoch immediately, not when a BRW is eventually submitted. A layout refresh within the epoch does not transfer this obligation to a later epoch. The exact acquisition and admission sequence is part of the unified epoch/layout protocol in [[#aw-lock-vs-layout-lock-interaction|§2.2.7]].
'''Admission and coverage.''' Every modifying IO obtains a valid layout, takes an AW reference, then checks that its soft LAYOUT grant is still valid before it acquires or reuses primary PW write authority or dirties data; if the layout was lost it drops the AW reference and retries from the layout ([[#aw-lock-vs-layout-lock-interaction|§2.2.7]]). So AW cancellation must wait even for an admitted IO that has not dirtied anything yet. Dirty data belongs to the epoch as soon as it is dirtied, not when a BRW is submitted, and keeps the file’s AW obligation alive after the IO ends; a layout refresh within the epoch does not move that obligation to a later epoch. Cached PW reuse and mmap write permission obey the same rule.


The client holds AW until no further data can be dirtied under the closing authority and every participating mirror has a resolved outcome: its updates are confirmed durable on OST storage, or its failure is recorded for the MDS. Recording failure alone does not authorize release: outstanding sends, retries, and replay must be completed or safely cancelled/fenced before AW and associated resources are released. Every mirror reported successful must have confirmed durability; an RPC acknowledgment alone is insufficient.
'''Release condition.''' The client holds AW until nothing more can be dirtied under it and every participant has a resolved outcome: its updates confirmed durable on OST storage, or its failure recorded for the MDS. Recording a failure alone does not permit release: outstanding sends, retries and replay must first complete or be safely cancelled or fenced. A mirror reported successful must be durable; an RPC reply alone is not enough.


The durability requirement for IWM is stronger than for normal Lustre writes. Without IWM, a buffered write flushed to OST page cache but not yet journal-committed is at risk if the OST crashes — but this is just data loss. With IWM, if one mirror’s OST commits while another does not, the mirrors silently diverge with no signal to the MDS. Mirror inconsistency is qualitatively worse than data loss: it persists silently and can corrupt reads after recovery.
This is stronger than for ordinary Lustre writes. Without IWM, a buffered write that reached OST memory but not the journal is lost if the OST crashes, which is data loss. With IWM, if one mirror’s OST commits and another’s does not, the mirrors silently diverge with no signal to the MDS, and that inconsistency can corrupt reads after recovery.


The client confirms journal commit on every mirror it reports successful before releasing AW. Mirrors that cannot meet that requirement follow the selected failure policy. Each OST import tracks <code>last_committed</code> (highest durably committed transno). At AW release:
'''The release barrier.''' Every AW release, whether forced, voluntary or idle, and even while other clients keep the epoch open, synchronously establishes these outcomes; there is no tunable to disable it, and no secondary timing policy weakens it. Each OST import tracks <code>last_committed</code>. At release:


# For each OST written during the epoch on a mirror not already recorded as failed, check whether the last write’s transno ≤ <code>last_committed</code>.
# For each OST object written during the epoch on a mirror not already recorded as failed, check whether the highest transno among its successful write replies is ≤ that import’s <code>last_committed</code>.
# If all remaining updates are committed and failed-mirror IO is safely resolved, release with the accumulated mirror-error summary.
# If all remaining updates are committed and failed-mirror IO is safely resolved, release with the accumulated error report.
# For remaining uncommitted updates, issue <code>OST_SYNC</code> and confirm durability. Secondary sync and recovery waits obey the selected fast-fail or slow-fail policy. A terminal inability to establish durability is recorded as a mirror failure and its outstanding IO safely resolved; release does not require that abandoned mirror to commit successfully.
# For objects still behind, issue an object-scoped <code>OST_SYNC</code> (<code>osc_sync_base()</code>) and confirm durability. Secondary sync and recovery waits follow the mount’s secondary failure policy. A terminal inability to establish durability is recorded as a mirror failure and its IO safely resolved; release does not require a failed mirror to commit.


The normal check costs one comparison per OST; explicit sync is needed when epoch close encounters uncommitted writes. The proposed <code>iwm_sync_on_epoch_close</code> tunable defaults to enabled. Disabling it explicitly opts out of the epoch-close durability guarantee and accepts mirror inconsistency in the client+OST crash window. It must not weaken the stable-storage contract of DIO or explicit durability requests. Neither fast-fail nor slow-fail implicitly disables commit verification.
This barrier does not make buffered <code>write()</code> synchronous, change the DIO or explicit-sync contracts, or require a synchronous commit of the MDT’s closing transaction.


'''Interaction with <code>sync_on_lock_cancel</code>:''' OSTs default to <code>sync_lock_cancel=always</code> (set by <code>ofd_slc_set()</code> when <code>sync_journal=0</code>), which forces journal commit when an extent lock is cancelled — protecting lock handoff between clients. The IWM sync mechanism is complementary: SLC protects other clients from seeing uncommitted data, while the AW release sync protects mirror consistency across crashes.
'''Existing mechanisms.''' At <code>47638add78</code>, every write request stays on the import replay list until the OST commits it, and its <code>rq_commit_cb</code> (<code>brw_commit</code>, <code>osc_request.c:2759-2771, 2922</code>) fires then; unstable-page accounting and soft sync (<code>OBD_BRW_SOFT_SYNC</code>) build on it. That gives commit notification per RPC but no per-file view, so IWM adds per-file tracking: for each OST object the file wrote during the epoch, the highest transno from successful write replies, recorded at reply time. PW lock cancellation (<code>osc_lock_flush</code>) waits for replies, not commits, so it cannot serve as the barrier. The OST <code>sync_lock_cancel</code> setting is not relied on: in effect it defaults to <code>never</code> (<code>ofd_slc_set()</code> sets <code>always</code> when <code>sync_journal=0</code>, but <code>tgt_init()</code> later resets it; <code>ofd_dev.c:3157</code>, <code>tgt_main.c:493</code>), and where enabled it covers only the primary’s OST.


'''Failure cases:''' A client that dies without releasing AW follows conservative eviction/recovery handling. For a live client, failures at submission, transport/recovery, modifying-RPC completion, or commit/sync are reported once terminal under the selected policy. A successfully recovered slow-fail event is not itself a final mirror error. Terminal failures cause STALE handling through the cancellation report and epoch close; they need not occur specifically at commit time.
'''DIO.''' Successful IWM DIO completion means stable storage, with or without synchronous flags: each participant’s updates are committed or recorded as failed, outstanding activity is resolved, and the primary determines the result. At <code>47638add78</code>, <code>osc_page_submit()</code> sets <code>OBD_BRW_SYNC</code> on every immediate transfer, including DIO (<code>osc_page.c:302</code>), and OFD sets <code>th_sync</code> whenever a niobuf lacks <code>OBD_BRW_ASYNC</code> (<code>ofd_io.c:1309-1319</code>), so a successful DIO reply is already journal-committed. Secondary DIO RPCs rebuilt by fan-out must carry the same flags. Buffered IO tracking continues after <code>write()</code> returns, through writeback, RPC completion and commit.


Successful IWM DIO completion requires stable storage, whether or not the caller supplied synchronous flags. Before completion, each participating mirror’s updates must be committed or recorded as failed, with outstanding activity safely resolved; the primary determines the result. This is an application contract, not an inference from <code>OBD_BRW_SYNC</code>. In the September 2026 source checkout (<code>47638add7815</code>), <code>osc_page_submit()</code> sets <code>OBD_BRW_SYNC</code>, while the OFD write-commit path requests a synchronous transaction when a buffer lacks <code>OBD_BRW_ASYNC</code>. The full DIO flag/commit path, including secondary fan-out and backend completion, still needs verification; any path without sufficient commit confirmation must explicitly obtain it. For ordinary buffered IO, tracking continues after <code>write()</code> returns, through writeback, RPC completion, and commit verification.
'''Failures.''' A client that dies without releasing AW is handled by eviction and recovery. For a live client, failures during submission, transport or recovery, the modifying RPC, or commit/sync are reported once terminal under the selected policy; a retryable event that recovers is not a mirror error. Terminal failures lead to STALE through the cancellation report and epoch close.


The AW lock is acquired via standard LDLM enqueue to the MDS and cached for reuse by subsequent writes to the same file. A proposed brief idle timeout (1-5 seconds) initiates AW flush/writeback; it does not by itself authorize release. The release path must first retire or gate the covered PW write authority and writable mappings, then resolve their dirty data and IO. A retained cached PW lock may not permit new dirtying after AW release without admission under valid AW coverage. This avoids per-write MDS round-trips while bounding idle epoch retention; the PW/AW cache-lifetime mechanism remains to be specified.
'''Caching and retention.''' The client caches its AW grant so that most writes run in an already-open epoch: each modifying IO validates its layout and takes a reference to the cached grant, with no MDT round trip. An AW enqueue that joins an unchanged epoch reuses it and a still-valid layout grant, without repeating primary selection, WRITE_PENDING/INFLIGHT publication, active-epoch insertion or layout replacement; it does record durable participation. Caching never bypasses layout validation or cancellation gates. Retention uses ordinary LDLM lock-use and recency semantics, with one difference: AW has its own shorter retention timer, proposed at about 60 seconds, so healthy secondaries do not stay INFLIGHT long after writing stops.
 
Timer expiry starts the normal release; it does not itself release AW or clear INFLIGHT. The client first gates or retires covered PW write authority and writable mappings (reusing <code>unmap_mapping_range()</code> with the AW admission interlock, [[#layout-lock-and-page-cache-interaction|§2.2.7.3]]), then drains admitted IO, flushes, and passes the release barrier. A cached PW lock may not allow new dirtying after AW release. The last holder’s release triggers the ordinary rollup that restores secondaries’ read eligibility.
 
There is no maximum epoch length. Because lock use resets the retention timer, a continuously written file keeps its epoch open and serves reads only from its primary until writing pauses; nothing is at risk, since every AW release is durable on all participants. A forced periodic rollup would drain and sync every writer only for the next write to reopen the epoch. Operations that need a closed epoch (resync, membership changes) force closure through LAYOUT EX → AW EX.
 
'''Open items.''' Exact AW reference points, timer/admission races, mmap fault and PTE handoff, and cached PW/AW lifetime integration (<code>lustre-design-docs-od5.36</code>, <code>lustre-design-docs-od5.8</code> P1–P3); the retention timer value and controls; confirming that fan-out preserves the DIO sync flags, and testing on both backends (A9).


<span id="error-reporting-via-cancellation-lvb"></span>
<span id="error-reporting-via-cancellation-lvb"></span>
=== Error Reporting via Cancellation LVB ===
=== Error Reporting via Cancellation LVB ===


When the client releases its AW lock, it communicates write results to the MDS in the client-to-MDS cancellation request. Adding this cancellation LVB is new protocol work; the existing LDLM_CANCEL format does not carry it. The report uses a versioned, length-delimited, extensible message structure from v1 onward. V1 sends only a mandatory mirror-error summary, with no per-stripe detail. Each reported mirror error causes whole-mirror STALE handling; the MDS combines reports from all clients against the same epoch mirror mapping. CLIO collects errors from all types of modifying operations covered by AW, including writes and setattr.
When the client releases AW, it reports write results to the MDS in the client-to-MDS cancellation request. This is new protocol: the existing LDLM_CANCEL format carries no LVB. The report is a versioned, length-delimited, extensible message ([[#byte-range-error-reporting-in-cancellation-lvb|§3.1]]). In v1 it carries the epoch ID and a counted list of failed mirror IDs, with no per-stripe detail. Errors are rare, so the list is usually empty; naming mirrors directly needs no numbering shared between client and MDS. The MDS validates each ID against the epoch’s participants (a report naming a mirror that was not written is invalid and gets conservative handling), takes the union of all valid reports for the epoch, and marks each named mirror STALE as a whole. CLIO collects errors from every modifying operation covered by AW, including setattr. Per-stripe error recording is TBD.
 
'''Durable acceptance.''' An error-bearing cancellation is acknowledged only after one MDT transaction has made durable both the reported errors and the removal of that client’s participation. Error records name the file, epoch and mirrors, with no durable association to the reporting client (debug logs may record it); in-memory receipt is not enough. Error records are distinct from participation: the reporter can release AW and retire its participation without waiting for rollup (holding AW until then would block the AW EX that resolves the error), and the error record survives until the closing transaction consumes it. Recovery therefore sees either the old participation, to be reconciled, or retired participation with its errors safely recorded, never participation removed with its errors lost. This transaction does not remove the active-epoch record.
 
'''Clean cancellations commit lazily.''' For zero-error cancellations, participation removal becomes durable with the next regular MDT commit; the MDT neither forces nor waits for a commit before acknowledging. The cost is a short window in which an MDT crash leaves the old participation record, and if that client also fails to reconnect, the file is degraded unnecessarily.
 
'''MDT commit cost.''' Where IWM forces an MDT commit wait, relative to delayed-write FLR:
 
{| class="wikitable"
|-
! Event
! Forced commit wait?
! Cost relative to FLR
|-
| Epoch entry (RDONLY → WRITE_PENDING, active-epoch record)
| yes
| none extra: FLR’s write-intent transition is already synchronous (<code>th_sync=1</code>, <code>mdd_object.c</code> at <code>47638add78</code>), and the record shares that transaction
|-
| Another client joining an open epoch
| yes
| new: one per client per file per epoch; the AW grant is then cached
|-
| Error-bearing AW cancellation
| yes
| new, rare
|-
| Clean AW cancellation
| no (lazy)
| an ordinary transaction, typically when cached AW expires, off the application IO path
|-
| Epoch close / rollup
| no
| an ordinary transaction
|}
 
Concurrent forced commits share journal commits (jbd2 batches them; ZFS waits on a txg), so the cost shows mainly as MDT commit pressure rather than application latency.


The mirror summary is a counted bitmap, not a fixed <code>uint16</code>: current Lustre defaults to a mirror limit of 16 but supports up to 256, requiring up to 32 bytes of bitmap payload. Bit positions refer to the epoch’s ordered immediate participant list, not directly to possibly sparse mirror IDs; that mapping must be shared by the MDS and clients and remain stable throughout the epoch. Future optional detail records can refine the report without changing v1’s conservative whole-mirror behavior. See [[#byte-range-error-reporting-in-cancellation-lvb|Cancellation LVB Format and Future Stripe Detail]] for the structure, numbering, and sizing rules.
'''Terminal errors release promptly.''' When an error becomes terminal under the selected policy, or administrative abandonment declares a secondary failed, the client resolves the remaining epoch IO and releases AW without waiting for the retention timer, reporting the failed mirrors. Any voluntary release carrying an error triggers rollup immediately, even with other holders ([[#epoch-close-mds-processing|§2.2.4]]). A retryable event that recovered does not.


When an error becomes terminal under the selected policy, the client resolves remaining epoch IO and releases AW without the normal idle-cache delay, reporting failed mirrors through the cancellation LVB. This triggers MDS epoch closure. Successfully recovered slow-fail events do not themselves trigger error-driven closure. Any new writer admitted before MDS EX acquisition must be included in closure under the same commit-or-record-failure rule.
'''Open items.'''
 
* '''Durable error-bearing CANCEL delivery (<code>lustre-design-docs-od5.29</code>).''' Stock LDLM cancellation does not deliver the report durably: at <code>47638add78</code>, <code>ldlm_cli_cancel_local()</code> (<code>lustre/ldlm/ldlm_request.c:1382</code>) cancels the local lock before the CANCEL RPC, and <code>ldlm_cli_cancel_req()</code> (<code>lustre/ldlm/ldlm_request.c:1598</code>) treats <code>LUSTRE_ESTALE</code> as success and tolerates other failures. The client must keep the pending report until durable acceptance, and the server must record it before lock destruction and reprocessing can lose its association. A stale or unknown cookie is not proof of acceptance; the epoch ID prevents applying it to a later epoch. Specify retries, lost acknowledgements, persistence failures, and reconnect when the cancelled lock is no longer replayed.
* Packing, limits and per-lock association in batched and early-cancel RPCs ([[#byte-range-error-reporting-in-cancellation-lvb|§3.1]]).


<span id="epoch-close-mds-processing"></span>
<span id="epoch-close-mds-processing"></span>
=== Epoch Close: MDS Processing ===
=== Epoch Close: MDS Processing ===


The MDS closes a write epoch when it holds no outstanding AW locks for a file. In the normal case, clients finish writing and release naturally. When a client reports errors, the MDS requests AW EX, forcing remaining writers to flush and resolve their epoch outcomes before release. Successful mirrors must be committed; failed mirrors must be reported with outstanding IO safely resolved. The same mechanism handles administrative operations such as mirror replacement and resync. On a hot file this can be expensive: every holder must finish the synchronous release path before EX is granted, and the selected slow-fail policy can prolong recovery waits until success or its give-up condition.
The last voluntary AW release triggers rollup, and so does any voluntary release carrying an error report, immediately, whatever other holders remain. A trigger starts closure; it does not mean the epoch is closed. The server always takes LAYOUT EX, then AW EX, even when there were no holders at the trigger. A clean last release and an error-triggered release enter the same sequence:


Once the MDS holds exclusive access, it must handle any evicted clients before transitioning the layout. Evicted clients will not receive the AW lock cancellation and may have writes in-flight that land on OSTs after the epoch close — see [[#client-eviction-from-mdt|Client Eviction from MDT]] and [[#mds-failover|MDS Failover]] for how this is handled (OST extent lock flush to maximise primary mirror completeness).
# '''The client drains its work.''' It finishes admitted modifying IO, flushes the file’s dirty data and resolves mirror IO and durability under its existing protection, then reports failed mirrors in the AW cancellation. It never releases AW with dirty data or unresolved admitted IO, and acquires no new distributed locks to finish old work.
# '''The MDT accepts responsibility durably''' ([[#error-reporting-via-cancellation-lvb|§2.2.3]]). The reporter is then done. The epoch, its active-epoch record and all accumulated errors remain.
# '''Rollup starts.''' The MDT requests LAYOUT EX, then AW EX. Until LAYOUT EX is granted, another client may still acquire AW and join the epoch; AW grants are not parked behind a “rollup pending” gate, which would create a circular wait.
# '''Remaining writers drain.''' LAYOUT cancellation invalidates their layout views. A restarting IO drops its AW reference before waiting for a fresh layout, while old dirty data keeps file-level AW protection. AW EX cancels and drains every remaining holder, including late joiners, each finishing with a file-wide flush and no layout reacquisition. Their error reports get the same durable acceptance.
# '''The MDT establishes the outcome''', with both EX locks held, from all reports and durable error records, using the outcome rules below.
# '''One transaction closes the epoch.''' It publishes the RDONLY layout with the resulting INFLIGHT/STALE flags, consumes the error records it handled, and removes the active-epoch record.


After evicted clients are handled, the MDS examines the error reports from all clients’ cancellation LVBs and transitions the layout:
'''Outcome rules,''' applied in this order (also used by recovery, [[#recovery-after-mds-failover|§2.2.5]]):


* '''No errors on a mirror:''' clear INFLIGHT — mirror is now clean and in sync.
* '''A known primary error first.''' Replace the failed primary with an eligible mirror that has no known failure, and mark the old primary STALE. If none is eligible, report the failures and keep the existing primary as the sole best-effort copy ([[#primary-mirror-write-failure|§2.4.4]]); this does not mean the failed writes succeeded.
* '''Errors on a mirror:''' clear INFLIGHT, set STALE — mirror requires full resync. lamigo is notified via changelogs (the same mechanism used by Hot Pools today) and attempts recovery — see [[#replacing-mirrorspermanent-failure|§2.4.5]].
* '''Then any lost participant.''' If a participant was evicted or lost, its outcome is unknown: every mirror other than the resulting primary becomes STALE in full, however few mirrors any reports named, and the primary is kept as a best-effort copy.
* '''Client eviction (no error report available):''' assume writes to all mirrors except the primary failed — clear INFLIGHT, set STALE on secondaries.
* '''Otherwise, ordinary results.''' Mirrors with reported errors become STALE; the others lose INFLIGHT and are clean. Pre-existing STALE is never cleared by a clean epoch; only resync clears it.


The layout transitions back to RDONLY. This transition bumps the layout generation (version). Note that the OST-side layout generation check (<code>ff_layout_version</code>) does not by itself fence late-arriving RPCs from evicted clients — the OST only rejects writes older than the minimum generation it has already seen, and it learns new generations from incoming writes rather than from the MDS. The actual fencing of evicted-client writes is provided by the OST extent lock flush described above.
STALE mirrors are visible to lamigo through changelogs, the same mechanism Hot Pools uses, and it attempts resync ([[#replacing-mirrorspermanent-failure|§2.4.5]]).
 
'''Atomic close, no extra commit wait.''' Recovery sees either the pre-close state (active-epoch record and error records) or the complete closing layout with that bookkeeping gone. If the close transaction is lost before it commits, the old evidence remains and recovery repeats rollup; if it commits, the layout carries the outcome. This holds even after every client has released AW, because the active-epoch and error records are not tied to participation. So close needs no dedicated synchronous commit; entry and error-bearing cancellation still do. Later epoch entry, resync and other layout operations must not become durable while losing an earlier close they depend on, nor reinterpret its errors as a later epoch’s. Within one MDT’s backend this ordering holds naturally: ldiskfs synchronous transactions wait for a journal commit and ZFS waits for the transaction group, so a later durable entry or participation record also makes the preceding close durable (<code>osd_trans_stop()</code> in both OSDs at <code>47638add78</code>). It is not a commit across MDTs or OSTs.
 
On a hot file closure is expensive: every holder must finish the synchronous release before AW EX is granted. Finite fast- and slow-fail timings bound secondary recovery waits; wait-indefinitely can keep closure waiting until recovery or administrative abandonment. Administrative operations such as mirror replacement and resync use the same sequence.
 
'''Lost participants: drain, then version fence.''' An evicted client cannot be counted as drained: it may still have old-version writes in flight and access to the OSTs. Its outcome is covered by the lost-participant rule above; late writes are rejected by the existing FLR write-era mechanism, not by an MDT-driven extent-lock flush (the MDT has no way to take OST extent locks). FLR’s era boundary is specific: a push carrying <code>LU_LAYOUT_RESYNC</code> raises the OST object’s minimum accepted version, while an ordinary generation update only widens the accepted range (era transition (<code>lustre/lod/lod_object.c:8742</code>), OST range handling (<code>lustre/ofd/ofd_objects.c:608</code>), old-version rejection (<code>lustre/ofd/ofd_io.c:443</code>)). The push travels from the MDT through OSP asynchronously (delivery conditions (<code>lustre/osp/osp_sync.c:244</code>)).
 
* '''Only closes with a lost participant are fenced.''' If every participant drained through AW cancellation, nobody retains write authority and no fence is needed. A lost-participant close, live or in recovery, pushes the era-ending version to every object of every participant mirror, including the primary; the next epoch’s entry then opens a new era above the lost client’s version.
* '''Close does not wait for delivery.''' After such a close every non-primary mirror is STALE and the primary is best effort. A late write to a STALE mirror is harmless; a late write to the primary is the application’s own data arriving late, with no other readable copy to diverge from. Publishing the layout, restoring readability and admitting the next epoch do not wait for the push.
* '''Copy-to-clean operations wait.''' Divergence becomes possible only when copies are made to agree again: resync, mirror extend, or any copy that clears STALE. Before reading the source mirror, such an operation must confirm the era-ending version is applied on the source objects; a queued OSP record is not confirmation. <code>osp_sync()</code> (<code>osp_dev.c:860</code>) waits for an OST’s pending records, with a timeout and <code>-ENOTCONN</code> when disconnected; a narrower per-object wait may be preferable. Target objects fence themselves, because resync writes carry <code>LU_LAYOUT_RESYNC</code>.
* '''Existing FLR resync has the same window (<code>lustre-design-docs-od5.49.31</code>).''' Resync start pushes <code>gen|LU_LAYOUT_RESYNC</code> to the primary asynchronously and copies immediately, with no group lock, lockless DIO reads and no data-version check at resync-done, so a writer evicted from the MDT but still connected to the OSTs can write the primary after resync copied that range. The gate above fixes FLR as well.
 
'''Open items.''' Era-ending sequencing (<code>lustre-design-docs-iwm.6.1</code>): MDT transaction and OSP delivery ordering, writes already prepared before the version check changes (bulk IO, commit, retries, replay), disconnected OSTs, and integration with the atomic close and crash recovery; whether close reuses SYNC_PENDING internally or an equivalent era-ending step. Atomic crash recovery and dependency ordering must be verified on ldiskfs and ZFS.


<span id="recovery-after-mds-failover"></span>
<span id="recovery-after-mds-failover"></span>
=== Recovery After MDS Failover ===
=== Recovery After MDS Failover ===


For recovery to succeed after MDS failover or crash, the MDS must determine which files had active IWM write epochs at the time of failure, so it can close those epochs safely. AW lock state (including which clients hold AW locks) is held in memory and lost on restart, so the MDS cannot simply reconstruct the epoch state from its in-memory lock tables.
AW lock state, including which clients hold AW, lives in MDT memory and is lost on restart. To close interrupted epochs safely, the MDT must know which files had open epochs, which clients took part, and which mirror errors were already reported. It keeps that in durable tracking.
 
The primary of an open epoch is recoverable from the layout itself: INFLIGHT is stored durably in <code>lcme_flags</code>, and the primary is the IMMEDIATE mirror without INFLIGHT. Non-immediate mirrors are never primary candidates merely because they lack INFLIGHT.
 
'''Durable tracking.''' The MDT keeps three logically distinct kinds of record:


If any client fails to reconnect during recovery (is evicted), the MDS closes active epochs for that client’s files with errors — see [[#client-eviction-from-mdt|Client Eviction from MDT]] for the eviction behavior. The primary mirror is always identifiable after a crash: INFLIGHT is durably recorded in the layout xattr (<code>lcme_flags</code>), and the primary is the one without INFLIGHT set (set as part of the durable layout transition to WRITE_PENDING at epoch open). If all clients reconnect successfully, recovery proceeds normally: clients replay their AW locks, the MDS reconstructs the in-memory epoch state, and epochs close as they would in the non-crash case.
* '''Active-epoch record''' (file plus epoch ID). Inserted by the opening AW request, in the same transaction that moves the layout to WRITE_PENDING and sets INFLIGHT. Removed only by the atomic closing transaction ([[#epoch-close-mds-processing|§2.2.4]]).
* '''Client participation''' (client, file, epoch). Recorded durably as part of every client’s AW acquisition, including joins of an open epoch, before that client’s write authority is usable. Retired by that client’s cancellation transaction ([[#error-reporting-via-cancellation-lvb|§2.2.3]]).
* '''Mirror-error record''' (file, epoch, failed mirror IDs). Recorded durably before an error-bearing cancellation is acknowledged; it carries no durable reporting-client association. Consumed only by the atomic closing transaction, so it survives the reporter’s AW release and participation retirement.


The central question is how the MDS identifies which files had active epochs. There are several possible approaches:
Each kind is independently updateable; there is no growing per-file client list rewritten on every join or release. The MDT also keeps an in-memory client → file/epoch lookup, maintained with live participation and rebuilt from the durable records on restart; it is not stored separately. Participation is recorded per client, rather than per file only, so that losing a client degrades only the files it was writing. The cost is one forced MDT commit per client join (see the cost model in [[#error-reporting-via-cancellation-lvb|§2.2.3]]); cached AW references add no further writes.


# '''Scan all inodes for INFLIGHT mirrors.''' Correct but prohibitively expensive — a large filesystem could have billions of inodes and only a handful of active IWM files.
Two alternatives were rejected: scanning all inodes for INFLIGHT mirrors (correct, but prohibitive on large filesystems) and relying on clients’ AW lock replay alone (fails exactly when a client is evicted, the case recovery exists for).
# '''Replay from client lock state.''' Clients replay their AW locks during recovery, which tells the MDS which files have active epochs. But this only works if all clients reconnect — if a client is evicted, its lock state is lost and the MDS has no way to know which files it was writing. This is exactly the case where recovery matters most.
# '''Durable epoch FID set.''' The MDS maintains a persistent set of FIDs with active write epochs, updated at epoch open/close time. On recovery, the MDS reads this set directly instead of scanning or depending on client replay.


'''Chosen approach: durable epoch FID set.''' When a file’s first AW lock is granted (transitioning the layout to WRITE_PENDING and setting INFLIGHT on secondaries), the FID is added to the set. When the epoch closes, the FID is removed. This is one durable write per file entering a write epoch, not per client or per lock grant. The set must support fast insertion/removal and could be very large (potentially millions of concurrent IWM files). The storage and indexing design is deferred to the implementation phase.
'''Recovery sequence.'''


During recovery, the MDS must first check the active-epoch FID set against current file state: files that are no longer IWM files (e.g., layout was changed before the crash) should be removed from the set. Then, if any client is evicted, the MDS iterates the remaining set and closes all active epochs with errors. This is conservative: the evicted client may not have held AW locks on all of these files, so some mirrors may be needlessly staled. But it is always safe and correct — mirrors that were genuinely consistent will simply be resynced unnecessarily.
# '''A close that became durable needs nothing.''' Its layout outcome is on disk and its tracking is gone. A close that did not become durable left its active-epoch and error records, so the epoch is simply still open.
# '''Load the tracking.''' Read active-epoch, participation and error records; mark epochs with error records as needing rollup. A recorded error does not need its reporter to reconnect.
# '''Finish ordinary lock replay and reconciliation.''' Rebuild surviving AW holders and match them to participation records. Do not cancel holders while replay is still assembling them, and do not make a missing-holder decision from partial state. A participant that fails to recover is lost and is evicted. An admitted client with no participation record is never assumed not to have written. A replayed AW lock with no matching active-epoch record (same file and epoch ID) is rejected; see below.
# '''Then roll up affected epochs with the ordinary close.''' Epochs with error records, and epochs left with no recovered holders, close through the same LAYOUT EX → AW EX sequence as a live close ([[#epoch-close-mds-processing|§2.2.4]]), without waiting for recovered holders to go idle. A writer that joins before LAYOUT EX is granted is drained by that same rollup. Independent files are processed by bounded parallel workers; the active records are the authority, so unfinished work is rediscovered after another restart without a progress journal.


Recovery iteration over the FID set should be parallelisable — each file’s epoch close is independent, and the set could be large. The storage structure chosen for the FID set should not force sequential processing; multiple recovery threads should be able to claim and process entries concurrently.
Each recovery rollup applies the ordinary outcome rules ([[#epoch-close-mds-processing|§2.2.4]]): a known primary error first, then the lost-participant rule (which affects only files whose participants did not all recover or release safely), then ordinary per-mirror results; late writes from a lost client are fenced as described there.


The key trade-off: v1 does not durably record ''which clients'' participate in ''which files’'' epochs. Recording per-file client participation would narrow the blast radius on recovery — staling only files where the evicted client actually held AW locks. But this requires a durable write on every AW lock grant (not just the first per file), and the storage must scale to potentially thousands of clients per file. Possible approaches include per-file xattrs storing client NIDs (limited by xattr size), NID range compression (ineffective for IPv6), and separate per-file extent trees (heavy). The cost-benefit balance is unclear — broad staling is always correct and costs nothing at runtime. Per-file tracking adds per-lock-grant IO cost and on-disk complexity. It is worth revisiting if broad staling proves problematic in practice.
Consumed error records and the active-epoch record are removed in the closing transaction itself; they are never deleted early, and never kept just to replay a released reporter’s lock. An active-epoch record for a file that is no longer an IWM file is removed only after its error records are handled.
 
'''Replayed AW without a matching active-epoch record.''' This violates the protocol invariant but must not crash the MDS: it depends on client replay state and index integrity. Reject the replay (the client drops the lock; evict the client if needed), log it with CERROR (an LASSERT may remain in debug builds), and handle the file conservatively. If an active-epoch record exists for a different epoch, the lost-participant rule applies; if none exists, open a recovery rollup that stales every mirror other than the primary. Never create the missing record or treat the file as clean.
 
'''Storage.''' Dedicated local DT indexes are the preferred candidate, not yet a chosen format. DT index operations (<code>lustre/include/dt_object.h:1532</code>) provide keyed lookup, declared insert/delete under a transaction handle, and iteration on both backends (local index creation (<code>lustre/obdclass/local_storage.c:514</code>); ldiskfs IAM (<code>lustre/osd-ldiskfs/osd_handler.c:6029</code>); ZFS ZAP (<code>lustre/osd-zfs/osd_index.c:1769</code>)), with fixed-size unique keys. Constraints found in the source (Lustre <code>47638add78</code>):
 
* '''Sharding:''' LFSCK’s hashed local trace indexes (<code>lustre/lfsck/lfsck_lib.c:2716</code>) are a precedent for FID-hashed shards in dedicated IWM objects (not shared OI/LFSCK records).
* '''No ordered scans:''' ZFS iterator seek (<code>lustre/osd-zfs/osd_index.c:1870</code>) is not implemented for nonzero keys and index negotiation (<code>lustre/osd-zfs/osd_index.c:2024</code>) rejects range and variable/non-unique formats, so recovery scans shards and groups records in memory.
* '''Same-transaction updates:''' index mutations and the layout xattr can share one local backend transaction (MDD orphan insertion (<code>lustre/mdd/mdd_orphans.c:82</code>) is a caller-owned precedent), but MDD layout change (<code>lustre/mdd/mdd_object.c:3543</code>) owns its transaction today and must be extended.
* '''LLOG:''' catalog append (<code>lustre/obdclass/llog_cat_server.c:443</code>) can join a caller transaction, but record cancellation (<code>lustre/obdclass/llog.c:198</code>) starts its own, so LLOG would need extra bookkeeping to meet the atomic boundaries.
 
'''Execution failures within a transaction.''' A shared transaction handle groups updates but does not undo them if a later mutation fails (ZFS stop (<code>lustre/osd-zfs/osd_handler.c:280</code>) commits an assigned transaction; ldiskfs stop (<code>lustre/osd-ldiskfs/osd_handler.c:2291</code>) closes the handle without rollback), and declarations do not make execution infallible (DT execution fault hooks (<code>lustre/obdclass/dt_object.c:1756</code>)). DT insertion does not overwrite (IAM (<code>lustre/osd-ldiskfs/osd_iam.c:2438</code>) rejects an existing key; ZFS uses <code>zap_add_uint64</code>), so error evidence must be added monotonically, never deleted and rewritten. Only a real commit callback (not <code>DCB_TRANS_STOP</code>) may satisfy the grant and error-ACK durability gates; target last-committed handling (<code>lustre/target/tgt_lastrcvd.c:937</code>) is a precedent for refusing to advance on commit error. Proposed execution order, stopping at the first failure (<code>lustre-design-docs-od5.61</code>):
 
# '''Opening:''' active-epoch record, then participation, then the opening layout; AW authority becomes usable only after all of it is durable. A join records only participation.
# '''Error-bearing cancellation:''' add every reported error before removing participation; acknowledge only after the whole handoff is durable. On partial failure keep participation, errors and the pending report for idempotent retry.
# '''Close:''' write the closing layout outcome first, then delete consumed errors, then the active-epoch record. Never continue cleanup after a failed outcome write or report success on an execution or commit error.
 
An abstract check of 20 failure points in this order found no lost evidence, but six close cases leave a closed layout with residual tracking (next item). It assumes each mutation applies wholly or not at all; it is not a backend fault-injection test.
 
'''Open items.'''
 
* '''Question for Andreas Dilger: recovery-phase placement.''' Should recovery rollups run as one additional final phase of recovery, before normal service resumes (Patrick’s tentative preference), or as ordinary operations after recovery, with their locks blocking only the affected files? Includes the recovery hook, when cancellation and drain are available, and service-resumption timing.
* '''Lock progress during recovery (<code>lustre-design-docs-od5.30</code>).''' A final-phase rollup cannot just call the ordinary close worker: LDLM reprocessing is suppressed during lock recovery (<code>lustre/ldlm/ldlm_lock.c:2401</code>) and ordinary RPCs wait for a full import (<code>lustre/ptlrpc/client.c:879</code>), so client CANCEL is delayed during replay. Define how LAYOUT/AW callbacks, cancellations, reprocessing, error persistence and drain make progress in that phase without admitting ordinary IO, keeping LAYOUT EX → AW EX.
* '''Decision needed (Patrick): failure-only close residue.''' With outcome-first close ordering, a cleanup deletion can fail after the closing layout has executed and committed, leaving a closed layout plus old tracking. Proposal: recognise a durable completed outcome for the same epoch and treat the surviving records as cleanup-only, never re-running outcome selection; reconcile before any later epoch or conflicting layout change, and without unambiguous recognition keep the evidence and block conflicting progress. RDONLY, a current-FID lookup or a missing lock is not enough to recognise it; no outcome or epoch tag encoding is chosen. The alternative is a proven atomic or rollback primitive.
* '''Detailed design (<code>lustre-design-docs-od5.21</code>, <code>lustre-design-docs-od5.61</code>):''' physical schema and key encoding; stable client identity across reconnect; duplicate or reacquired grants; replay matching; distinguishing a safely released participant from a lost one; index bootstrap; shard count and versioning; iteration and update races (never hold an index iterator or lock across distributed-lock waits; verify ZAP cursor behaviour under mutation and scan completeness); memory bounds; both-backend crash and fault-injection tests. Per-stripe error records remain TBD.


<span id="flush-semantics-on-lock-release"></span>
<span id="flush-semantics-on-lock-release"></span>
=== Flush Semantics on Lock Release ===
=== Flush Semantics on Lock Release ===


AW flush/writeback covers all data dirtied under the client’s PW write authority for the epoch, including data not yet submitted in any RPC. Before release, further dirtying under that authority must be stopped: drain active users and revoke or gate cached PW reuse and writable mmap mappings. A one-time dirty-page scan is not sufficient if those permissions can dirty pages again. New writes must obtain valid AW coverage after admission resumes. The mechanism for doing this is part of the epoch/layout protocol, not a decision to defer AW acquisition until BRW submission.
AW cancellation drains admitted modifying IO, including operations that have not dirtied anything yet and DIO with no cached pages, then performs a final file-wide flush of all dirty data, including data never submitted in an RPC, and resolves every outstanding mirror outcome (the release barrier, [[#lock-lifetime-and-commit-requirements|§2.2.2]]). Writeback may start earlier, but a dirty-page scan taken before admitted IO has drained is not the barrier, because that IO could still dirty data. AW is never released while dirty data, or an admitted operation that could produce it, remains.


The AW lock has two release paths with the same completion condition: covered dirtying authority is quiesced, each participating mirror’s updates are committed or recorded as failed, and all outstanding epoch IO is safely resolved. The cancellation LVB distinguishes successful mirrors from failed ones. Neither path requires successful commit on a conclusively abandoned mirror.
The drain runs under the protection the client already holds. It takes no new layout, AW or extent lock, and existing object mappings and references stay usable until the work is resolved. Local synchronization is allowed; a new distributed-lock dependency in the cancellation path is not.


'''Voluntary release''' (client-initiated, no conflicting lock request): Quiesce the covered write authority, finish epoch writeback, confirm commit for successful mirrors, and resolve failed-mirror IO as described in [[#lock-lifetime-and-commit-requirements|§2.2.2]]. Release with the accumulated mirror-error summary. A terminal failure bypasses idle caching and starts this resolution/reporting path promptly.
The client must associate admitted modifying IO, PW write authority, dirty pages, in-flight RPCs and commit/error tracking with the current epoch, across buffered syscall return and layout refresh. RPC reference counts or <code>lo_active_ios</code> alone do not cover idle dirty pages or cached write permission. A file-wide flush avoids needing per-page epoch ownership just to choose what to flush.


'''Forced revocation''' (MDS requests EX lock, blocking AST fires): Start quiescing and flushing immediately, then follow the selected policy to obtain committed or terminally failed outcomes. Slow-fail may spend time in standard retry/recovery, but its give-up mechanism must remain effective during forced closure. The blocking AST cannot complete AW release until covered dirtying authority is quiesced, successful mirrors are durable, and failed-mirror IO is safely resolved. Expiring a timer or setting an error bit alone does not complete that work.
There are two release paths with the same completion condition:


Both paths enforce the same commit-or-record-failure condition. The client or MDS chooses when flushing begins; the secondary failure policy controls retry persistence. Neither mode permits late retries to escape the closed epoch and interfere with later IO, resync, or mirror reuse.
* '''Voluntary release''' (idle timer or an error, no conflicting request). There is no server-held layout EX, so the client gates new modifying IO and write faults locally and revokes writable mappings with <code>unmap_mapping_range()</code> ([[#layout-lock-and-page-cache-interaction|§2.2.7.3]]), accounting for faults already past admission at the locked-page/PTE handoff. Dirty cache and existing PW, object and AW protection are kept. It then flushes, passes the release barrier, and releases with its error report. A terminal failure skips the idle timer. The last voluntary release triggers rollup, and an error-bearing one triggers it immediately ([[#epoch-close-mds-processing|§2.2.4]]).
* '''Forced revocation''' (the MDS holds layout EX and requests AW EX). Layout revocation has already blocked new IO and unmapped writable mappings; a cached AW or PW lock is not a bypass. The client starts draining and flushing at once. The blocking AST completes only when admitted IO has drained, the final flush is done, successful mirrors are durable and failed-mirror IO is resolved; a timer expiring or an error bit being set does not complete that work. Finite fast- and slow-fail budgets apply during forced closure; wait-indefinitely stays unbounded (administrative abandonment is the separate way to act on its outstanding work). An IO that must restart for a fresh layout leaves the old drain before waiting for that layout, while its dirty data stays covered by the file’s AW obligation.


AW flush/writeback does not semantically require discarding successfully written cached data. However, existing PW cancellation flushes and discards pages, so choosing to reuse that path may invalidate cache as an implementation consequence. Retaining clean pages requires a mechanism that still removes or gates write permission; retaining writable mmap mappings without AW coverage is not valid. Secondary failure alone does not require discarding data when the primary succeeded; primary/writeback errors retain normal error and page-state handling.
Neither path lets late retries escape the closed epoch and interfere with later IO, resync or mirror reuse, and neither requires a conclusively failed mirror to commit.


Both release paths require the client to associate PW write authority, dirty pages, in-flight RPCs, and commit/error tracking with the current epoch. This association must survive buffered syscall return and layout refresh. RPC reference counts or <code>lo_active_ios</code> alone cannot be assumed to cover idle dirty pages or cached write permission. The choice of PW lifetime coupling, page/range tracking, and flush references remains open.
Release does not need to discard cached data. <code>unmap_mapping_range()</code> removes file-backed PTEs without discarding dirty pages; <code>vvp_prune()</code> and fake layout invalidation are not used just to revoke mapping authority. (Reusing the complete PW cancellation path would discard pages as a side effect, since <code>osc_lock_flush()</code> writes back and then discards.) Writable mappings may not outlive AW coverage. A secondary failure alone never discards data when the primary succeeded; primary and writeback errors keep normal handling.


For evicted clients that cannot respond to lock callbacks, OST extent locks (<code>LCK_PW</code> on the full file extent) can be used to force-flush the evicted client’s dirty data on the OSTs. This mechanism is used during both live eviction and MDS recovery eviction — see [[#mds-failover|MDS Failover]] for details.
Evicted clients cannot be relied on to cancel or flush; they are handled by the lost-participant rule and version fence in [[#epoch-close-mds-processing|§2.2.4]], not by a full-extent PW sweep.


Existing mechanisms provide pieces of AW flush/writeback, not yet a complete protocol. PW cancellation reaches <code>osc_lock_flush()</code>, which writes back a write-locked range and then discards pages. Layout invalidation itself only invalidates the layout and unmaps mmap pages; installing a changed layout through <code>cl_conf_set()</code> / <code>lov_conf_set()</code> can subsequently reach <code>lov_layout_change()</code> → <code>cl_object_prune()</code> → <code>vvp_prune()</code>, which flushes and tears down the cache. <code>CL_FSYNC_LOCAL</code> in that prune path waits for writeback, not the full IWM durability barrier. Existing FLR resync explicitly triggers <code>LL_DV_WR_FLUSH</code> before layout revocation. Decide which pieces AW can reuse, whether PW locks must actually be cancelled, and how layout-driven flushing retains the old epoch association without recursively requesting new AW admission.
Existing paths to reuse, with their limits (<code>47638add78</code>): layout invalidation only invalidates and unmaps; applying a changed layout (<code>cl_conf_set()</code> / <code>lov_conf_set()</code> → <code>lov_layout_change()</code> → <code>cl_object_prune()</code> → <code>vvp_prune()</code>) flushes with <code>CL_FSYNC_LOCAL</code>, which waits for writeback, not commit, and then tears down the cache. The <code>CIT_FSYNC</code> path skips layout refresh in <code>vvp_io_init()</code>, and <code>cl_sync_file_range()</code> can use existing object mappings with <code>ci_ignore_layout</code>.
 
'''Open items''' (<code>lustre-design-docs-od5.8</code> P1–P3, <code>lustre-design-docs-od5.36</code>): exact IO reference boundaries and the final-flush barrier; the voluntary-release admission gate; mmap fault/unmap races; preservation of old object and PW references through pruning and reconfiguration; local configuration-lock and fan-out callback dependencies (no local wait cycles).


<span id="aw-lock-vs-layout-lock-interaction"></span>
<span id="aw-lock-vs-layout-lock-interaction"></span>
=== AW Lock vs Layout Lock Interaction ===
=== AW Lock vs Layout Lock Interaction ===


The AW lock and the layout lock track distinct concerns. The layout lock tracks ''what the layout is'' — stripe configuration, component structure, mirror membership. The AW lock tracks ''the write epoch'' — who is writing, what phase they are in, and when the epoch closes. These must not be merged — they are separate concerns — but they interact closely.
The layout lock tracks ''what the layout is'' (stripes, components, mirror membership); the AW lock tracks ''the write epoch'' (who is writing and when the epoch closes). They are separate locks that interact closely.


'''Lock ordering:''' Clients acquire the layout before AW. MDS sequencing depends on the operation; epoch closure and administrative layout changes have different paths:
'''Lock order: LAYOUT before ACTIVE_WRITERS, everywhere.''' The client’s layout hold is soft; the server takes EX on both, layout first, for any operation needing both. There is no exception.


* '''Client write path:''' The client takes the layout lock first (learning the current layout). If the layout has immediate mirrors, the client then takes the AW lock and proceeds with write duplication. If the layout does not require immediate mirroring (e.g., delayed-write FLR, or no mirrors), no AW lock is taken.
* '''Client admission.''' Obtain LAYOUT, acquire or reference AW at the top of the modifying IO, then check that the LAYOUT grant is still valid and that the layout and AW epoch are compatible, before any PW use or dirtying. If the layout was lost, drop the IO’s AW reference (returning an unused new grant through normal release) and retry from LAYOUT; never wait for a fresh layout while holding the old IO’s AW reference. Old dirty data keeps the file’s AW protection until the normal drain. The same sequence applies to cached AW. Files that do not need immediate mirroring take no AW.
* '''IO restart on layout change:''' If the layout changes during IO (layout lock revoked), the client’s IO restarts from the layout lock. The AW lock is '''not''' revoked by an intra-epoch layout change — it is a separate IBITS bit and survives independently (see [[#why-separate-locks|Why Separate Locks]]). Pending dirty data retains its epoch association through restart. Removing immediate mirroring is instead an epoch-boundary operation: resolve the old PW/dirty-data/AW obligations before granting write authority under the new delayed-write layout. Discovering a different layout is not permission to drop an unresolved AW obligation.
* '''IO restart on a layout change.''' An ordinary layout change within an epoch revokes LAYOUT but not the AW grant, which can stay cached; the restarting IO drops its own AW reference and readmits. Dirty data keeps its epoch association.
* '''Epoch close (non-structural, MDS-initiated):''' The MDS takes EX on the AW lock first, forcing all writers to flush dirty pages, report errors via cancellation LVB, and release. Once the epoch is closed, the MDS updates the layout flags (INFLIGHT/STALE) via a layout lock cycle — layout EX second. Because this is a flag-only change, the <code>STATE_ONLY_CHANGE</code> optimization applies: clients re-acquire layout CR without a page cache nuke.
* '''Epoch close and administrative operations''' (resync, mirror replacement or addition, IMMEDIATE membership changes): layout EX, then AW EX, with exclusion held through the operation ([[#epoch-close-mds-processing|§2.2.4]]). Layout EX alone is not a flush barrier. Structural changes require full page-cache teardown; flag-only changes can use <code>STATE_ONLY_CHANGE</code> ([[#layout-lock-and-page-cache-interaction|§2.2.7.3]]). Membership changes follow the conditional rules in [[#combining-immediate-and-non-immediate-mirrors|§4.2]].
* '''Administrative operations''' (resync, mirror replacement, mirror add, changes to IMMEDIATE membership): Block new epoch participants and quiesce existing writers before changing the layout. The proposed layout EX → AW EX sequence must satisfy the flush dependency requirement below; layout EX alone is not a flush barrier. Structural changes require full page cache teardown on re-acquisition. Adding or removing IMMEDIATE changes the next epoch’s participants and must follow the conditional transition rules in [[#combining-immediate-and-non-immediate-mirrors|§4.2]].
* '''Leaving IWM.''' Removing immediate mirroring is an epoch boundary: the old PW, dirty-data and AW obligations are resolved before delayed-write authority is granted, and later writes under that layout take no AW. A changed layout never retires pending obligations by itself.
* '''Non-IWM layout acquisition''' (including ACCESS and layouts returned on open, not just write intents): Close any active epoch before granting a usable layout, following [[#conflicting-operations-during-write-epochs-resync-old-clients|§2.4.6]]. A read-only handoff is not itself structural or a reason to stale healthy mirrors. Legacy clients use their existing layout refresh path.
* '''Non-IWM access''' follows the capability gate in [[#conflicting-operations-during-write-epochs-resync-old-clients|§2.4.6]]. Reads by capable clients never force closure. Optional primary-only writes reuse ordinary FLR write intents, closing any open epoch with LAYOUT EX → AW EX first.


'''Flush dependency requirement:''' Closing an epoch must block new writers without preventing existing AW holders from completing flushes and any required layout refresh or IO restart. An MDS operation must not hold layout EX while waiting for AW cancellation if that cancellation needs layout CR. The implementation must serialize epoch close, layout publication, and epoch admission without this circular wait; the concrete mechanism remains to be specified. This also applies when an old client’s layout request triggers closure.
'''Forced close, from the client’s side.'''


'''PW coverage and layout reconfiguration:''' Admission must cover PW acquisition and cached-lock reuse before dirtying, including mmap write faults. During AW flush/writeback, completion of existing dirty data remains authorized by the closing AW; it must not be treated as a new writer that waits behind its own closure. An intra-epoch refresh must preserve pending dirty-data obligations; a membership change or compatibility handoff must resolve them before new write authority becomes usable. The acquire, flush, refresh, and release sequence is one open design problem, tracked in the accompanying <code>iwm_discussion_checklist.md</code> as shared questions P1–P8. Start with what PW cancellation and <code>cl_conf_set()</code> / object pruning need to complete: those dependencies may determine layout/AW ordering, admission, and epoch-publication choices. They are not downstream implementation details of an otherwise settled protocol.
# The MDS obtains layout EX. Clients’ layout holds and writable mappings are invalidated, and no new IO can obtain a valid layout.
# The MDS requests AW EX. Each client drains the IO already admitted under AW, even IO that has not dirtied anything.
# The client performs the final file-wide flush under its existing protection and mappings, with no new locks, and passes the release barrier.
# It reports its outcomes and releases AW. When every holder has released, the MDS holds both EX locks and finishes the close or administrative operation. Lost clients are handled by the fence in [[#epoch-close-mds-processing|§2.2.4]].


The AW requirement is conditional on the layout under which write authority was granted. An intra-epoch layout refresh preserves existing AW coverage and dirty-data obligations. A transition out of IWM must resolve those obligations before delayed-write authority becomes usable; subsequent writes under that layout do not acquire AW. The changed layout alone cannot retire pending obligations from the previous epoch.
The layout gate stops new IO; AW cancellation finishes old IO. The closing flush never refreshes the layout or readmits itself behind the server’s layout EX, which removes the layout-EX / AW-cancellation / layout-CR circular wait.
 
'''Epoch entry reply: updated layout plus a replacement LAYOUT lock.''' The opening AW request changes the layout under MDT layout EX, which revokes the requester’s soft layout lock. The AW enqueue reply therefore returns the updated layout and a replacement LAYOUT grant together with the AW grant. These are two independent locks in one reply, not a merged-bit lock and not LDLM lock conversion; returning layout bytes without a grant would not do. The MDT makes the epoch durable under LAYOUT EX, then hands over the replacement LAYOUT grant followed by AW, serialized so that no incompatible transition can slip in between and make the pair obsolete (sharing one reply does not by itself guarantee this). The client refreshes the layout under the replacement grant and validates that grant, not the revoked one, before PW use or dirtying; if the grant is cancelled before validation, it retries normally even though AW arrived. A join of an unchanged epoch can keep a still-valid layout grant.
 
This reuses FLR’s existing handoff: ordinary write intents already change the layout, return the layout grant via <code>mdt_intent_lock_replace()</code> with an EX-to-CR downgrade in <code>mdt_intent_layout()</code>, and let the client apply it through <code>ll_layout_refresh()</code> / <code>ll_layout_lock_set()</code> (<code>47638add78</code>). Only the independent AW grant is new.
 
'''Open items''' (<code>lustre-design-docs-iwm.3.2</code>, <code>lustre-design-docs-od5.8</code> P1–P6): the reply representation for both grants; cancellation, partial or lost replies, failure, retry and reconnect handling; ordered-handoff serialization; exact admission hook placement (<code>ll_file_io_generic()</code> is the likely ordinary-write hook; all paths that bypass it need equivalent coverage, [[#client-implementation|§2.3]]); local lock dependencies during pruning and fan-out.


<span id="why-separate-locks"></span>
<span id="why-separate-locks"></span>
==== Why Separate Locks ====
==== Why Separate Locks ====


The AW lock lives on a separate IBITS bit from the layout lock (see [[#lock-semantics|Lock Semantics]]), not a mode or flag on the layout lock itself. This separation is deliberate — combining them would create fundamental problems:
AW is a separate IBITS bit, not a mode or flag on the layout lock, for three reasons:


'''The layout lock is light; the AW lock is heavy.''' The layout lock is designed to be held “lightly” — the client reads the layout, then the lock can be revoked at any time without flushing IO. The client simply restarts the IO under the new layout. This is essential for operations that change the layout frequently (component instantiation in PFL files, self-extending layouts). The AW lock has the opposite semantic: its blocking AST forces a full flush and waits for OST commit before returning. If both semantics lived on the same lock, every layout revocation would require flushing all in-flight IO — turning a cheap IO restart into an expensive synchronous flush. For a many-component PFL file written sequentially, this would create an unnecessary epoch close at every component boundary.
'''The layout lock is light; the AW lock is heavy.''' The layout lock can be revoked at any time without flushing; the client simply restarts IO under the new layout, which matters for layouts that change often (PFL component instantiation, SEL). AW revocation forces a full flush and durable commit. On one lock, every layout revocation would become an expensive synchronous flush, and a sequentially written many-component PFL file would close its epoch at every component boundary.


'''The epoch must span layout changes.''' Component instantiation, SEL extension, and other layout changes revoke the LAYOUT bits but should not close the write epoch. The mirrors are the same, the write session is ongoing, and nothing about epoch consistency semantics changed. Because ACTIVE_WRITERS is a separate bit, taking EX on LAYOUT does not revoke it — the AW lock survives layout changes naturally. The epoch only closes when the MDS explicitly takes EX on ACTIVE_WRITERS (error handling, resync, admin) or all clients voluntarily release their AW locks.
'''The epoch must span layout changes.''' Component instantiation and SEL extension revoke LAYOUT but should not close the epoch: the participants and the write session are unchanged. Because ACTIVE_WRITERS is a separate bit, layout EX does not revoke it. The epoch closes only when the MDS takes AW EX (errors, resync, administration) or every client releases AW.


'''MDT/OST dependency constraint.''' AW is held across OST IO, so separating the locks does not by itself prove the dependency graph acyclic. Writers’ CW locks do not conflict, but an MDS AW EX request waits for their flushes. Those flushes may need layout refresh after invalidation, and another client’s IO can trigger closure through a legacy layout request. Both ordinary closure and administrative or compatibility handoffs must meet the flush dependency requirement above.
'''No MDT/OST dependency cycle.''' AW is held across OST IO, and an AW EX request waits for that IO while holding layout EX, so cancellation must never need a layout refresh; it uses existing mappings and protection. Local configuration guards, object pruning and asynchronous fan-out callbacks still need checking for local cycles.


'''Alternatives considered:''' Combining the bits onto one lock with conditional flush semantics (flush only when in an IWM epoch) was considered, but this effectively reinvents the AW lock as a flag on the layout lock while adding mode-dependent complexity to every layout lock consumer. LDLM lock conversion (acquiring both bits, then converting to drop LAYOUT while keeping ACTIVE_WRITERS) could save a round trip but couples the implementation to a lightly-exercised LDLM feature with subtle recovery and race implications. Both are potential v2 optimizations; v1 uses separate locks for simplicity and debuggability.
Combining both bits on one lock with conditional flush semantics was rejected: it reinvents AW as a layout-lock mode and complicates every layout-lock user. LDLM lock conversion (take both bits, later drop LAYOUT) could save a round trip but depends on a lightly exercised feature with subtle recovery behaviour; it remains a possible future optimization. The entry reply above already returns both grants in one round trip.


<span id="behavior-during-layout-changes"></span>
<span id="behavior-during-layout-changes"></span>
==== Behavior During Layout Changes ====
==== Behavior During Layout Changes ====


This section describes non-destructive layout changes within an existing IWM epoch (component instantiation, FLR flag transitions that preserve the immediate participant set) where only the layout lock is involved and AW is left untouched — the <code>STATE_ONLY_CHANGE</code> path described in [[#layout-lock-and-page-cache-interaction|§2.2.7.3]]. It does not apply to destructive changes, IMMEDIATE membership changes, epoch boundaries, or a handoff to a non-IWM client. Legacy layout acquisition closes an active epoch before returning the layout, even for a read — see [[#conflicting-operations-during-write-epochs-resync-old-clients|§2.4.6]].
This covers non-destructive layout changes within an epoch (component instantiation, SEL extension, flag changes that keep the participant set), where only the layout lock is involved and AW is untouched. It does not cover destructive changes, IMMEDIATE membership changes or epoch boundaries.


When a non-destructive layout change occurs while a client holds both the layout lock and the AW lock (e.g., another client’s write intent triggers component instantiation):
'''Component instantiation covers every participant.''' A write intent instantiates the needed components of every participating IMMEDIATE mirror, each according to its own layout, before fan-out to them; this extends the MDT’s existing primary-mirror instantiation and involves no client-side component creation. A secondary instantiation failure is a secondary modifying-IO failure: that mirror ends the epoch STALE, and a successful primary write is not turned into an application error. A primary instantiation failure follows the primary failure rules. Resync must also instantiate the components it needs; if that keeps failing, resync fails and the mirror stays STALE until a retry succeeds. Partial-success replies and their error recording are implementation work (<code>lustre-design-docs-od5.49.12</code>).


# The server takes EX on LAYOUT, revoking all clients’ layout locks via blocking AST.
When such a change happens while a client holds both locks:
# The layout lock blocking AST fires on the client and returns immediately — no flush, no awareness of AW state. This is unchanged from today’s behavior.
# In-flight OST writes either complete normally or are rejected by OSTs (stale layout generation), triggering IO restart.
# The client’s IO restart path detects the layout change and restarts the IO from the top.
# The AW lock remains held throughout — the epoch is still open.
# On restart, the client re-acquires the layout lock (via write intent if needed), gets the new layout, and continues writing under the same AW epoch.


The key property: '''layout changes are not epoch boundaries.''' This is safe because ongoing IO will simply be restarted at the end, as it is for any layout change. In-flight writes that land on OSTs with a stale layout generation are rejected, and the client retries them under the new layout. The AW cancellation LVB accumulates errors across layout changes, and the epoch close (whenever it eventually happens) handles them all.
# The server takes LAYOUT EX, revoking every client’s layout lock.
# The client’s layout blocking AST returns immediately, with no flush and no AW involvement, as today.
# OST writes already in flight complete normally. An ordinary generation change inside a write era only widens the OST’s accepted version range, so they are not rejected ([[#epoch-close-mds-processing|§2.2.4]]).
# An IO that needs the new layout drops its AW reference and restarts from the top, then waits for the fresh layout.
# The AW grant stays held or cached, not pinned by the restarting IO; old dirty data and the epoch are untouched.
# On restart the IO reacquires LAYOUT (through a write intent if needed), references the AW grant and validates the layout before continuing in the same epoch. If AW was meanwhile cancelled for closure, the old drain finishes first and the IO retries admission.
 
So layout changes are not epoch boundaries. Errors accumulate across them, and the epoch close handles all of them.


<span id="layout-lock-and-page-cache-interaction"></span>
<span id="layout-lock-and-page-cache-interaction"></span>
==== Layout Lock and Page Cache Interaction ====
==== Layout Lock and Page Cache Interaction ====


Layout lock re-acquisition has a hidden cost: the client’s entire page cache for the file is destroyed. Understanding this mechanism is critical for IWM performance.
'''Why layout changes are expensive today.''' When a client reacquires its layout lock after revocation, <code>ll_layout_lock_set()</code> applies the new layout via <code>lov_conf_set(OBJECT_CONF_SET)</code>, and <code>lov_layout_change()</code> tears down and rebuilds the <code>lov_object</code>, destroying every sub-object (<code>osc_object</code>). Before teardown, <code>cl_object_prune()</code> → <code>vvp_prune()</code> flushes all dirty pages with <code>cl_sync_file_range()</code> and then drops the whole page cache (<code>ll_truncate_inode_pages_final()</code>): each cached page is attached to one sub-object and cannot outlive it. That is necessary for structural changes. Delayed-write FLR changes layouts only on idle files, so the cost is small. IWM changes them on files under active IO with hot caches on many clients, so dropping the cache at every epoch transition would make buffered IWM IO unacceptably slow.


'''The mechanism:''' When the client re-acquires its CR layout lock after revocation, <code>ll_layout_lock_set()</code> installs the new layout via <code>lov_conf_set(OBJECT_CONF_SET)</code>. This calls <code>lov_layout_change()</code>, which tears down and rebuilds the <code>lov_object</code> — destroying all sub-objects (<code>osc_object</code> instances representing individual stripes). Before teardown, <code>cl_object_prune()</code> → <code>vvp_prune()</code> calls <code>cl_sync_file_range()</code> to flush all dirty pages, then <code>ll_truncate_inode_pages_final()</code> to nuke the entire page cache. The nuke is necessary because each cached page (<code>cl_page</code> → <code>osc_page</code>) is pinned to a specific sub-object; when those sub-objects are destroyed, the pages cannot survive.
'''Non-destructive changes keep their sub-objects:''' FLR state transitions (RDONLY → WRITE_PENDING, INFLIGHT and STALE flag changes) change only per-component flags, and component instantiation (PFL, SEL) only adds sub-objects. For these, rebuilding the <code>lov_object</code> is unnecessary, but not every cached page can be kept.


'''Why this exists:''' Layout changes can alter the stripe structure — different OSTs, different components, different stripe counts. The sub-objects for the old layout are invalid after the change. Tearing down and rebuilding is the only safe approach for structural changes.
'''Read-eligibility rule.''' Each cached page belongs to one mirror’s sub-object, and clients choose their read mirror independently (per-client hash plus preference, <code>lov_object.c:770-804</code>), so pages cached from a non-primary mirror are normal. During an epoch, writers lock only the primary and secondary BRWs carry no client lock, so nothing would revoke another client’s pages on a secondary that has just become INFLIGHT; they would go stale. A writer that found such a page would dirty a page owned by a secondary sub-object, which <code>lov_io_sub_init</code> asserts against (<code>lov_io.c:111-116</code>). Today the full prune hides this. So when a state-only change is applied, every mirror that goes from readable to not readable has the pages and extent locks on its sub-objects discarded; mirrors that stay or become readable keep their cache:


'''Why this matters for IWM:''' Delayed-write FLR only mirrors inactive files — there is no hot page cache during layout transitions, so the nuke has negligible cost. IWM is the opposite: it targets files under active sustained IO with hot page caches on multiple clients. If every epoch transition (setting INFLIGHT, clearing STALE) required a layout lock cycle with page cache nuke, IWM performance under buffered IO would be unacceptable.
* '''Epoch entry:''' the secondaries become INFLIGHT, so their sub-objects are pruned; the primary’s pages survive.
* '''Epoch close and component instantiation:''' no prune, because INFLIGHT mirrors are never read and so have nothing cached.
* '''A primary replaced at close''' loses eligibility, so its sub-objects are pruned.


'''Non-destructive layout changes.''' Not all layout changes alter the stripe structure. Two categories preserve existing sub-objects:
This needs no new wire data. The prune already runs at layout refresh, not at cancellation: cancellation only invalidates and unmaps (<code>vvp_conf_set(OBJECT_CONF_INVALIDATE)</code>), and the refresh holds both the old and new layout, so the client compares per-mirror eligibility and prunes only the affected sub-objects (<code>lov_delete_raid0</code> already prunes per sub-object). Between invalidation and refresh, fast read cannot serve stale pages, because <code>ll_read_from_cache</code> declines while the layout generation is <code>CL_LAYOUT_GEN_NONE</code>. Server-side locking on secondary BRWs was considered instead and rejected: it cannot cover the writing client’s own secondary-owned pages, which can be dirtied before any secondary BRW is sent.


# '''FLR state transitions''' (RDONLY → WRITE_PENDING, epoch flag updates to INFLIGHT/STALE flags): same components, same stripes, same OSTs. Only per-component flags change.
'''The <code>STATE_ONLY_CHANGE</code> flag.''' The MDS sets a transient flag in the layout when the latest <code>layout_gen</code> bump was non-destructive, only for clients that advertise support through an <code>OBD_CONNECT2</code> bit. On reacquiring the layout lock:
# '''Component instantiation''' (PFL extend, SEL): new sub-objects are added for the new component, but existing sub-objects are untouched. Pages cached against existing components remain valid.


For these changes, the page cache nuke is unnecessary — the existing sub-objects are still valid and pages referencing them are still correct.
* If the client’s <code>layout_gen</code> is exactly N-1 and the new layout carries the flag, it updates flags (and adds sub-objects for new components) in place in the existing <code>lov_stripe_md</code>, skips the whole-file <code>cl_object_prune()</code>, and applies the read-eligibility rule.
* Otherwise (flag absent, more than one version behind, or a destructive change) it does the full teardown as today.


'''The optimization: <code>STATE_ONLY_CHANGE</code> flag.''' To avoid the page cache nuke on non-destructive changes, the MDS sets a transient flag (<code>STATE_ONLY_CHANGE</code> or similar) in the layout when the most recent <code>layout_gen</code> bump was non-destructive. A connect flag (<code>OBD_CONNECT2</code> bit) gates this — the MDS only sets the flag for clients that advertise support.
The flag describes only the most recent transition, hence the N-1 rule. The optimization is purely opportunistic: old clients never see the flag, clients more than one version behind ignore it, an MDS restart loses it, and its absence for any reason means a full teardown. It is separate from the safe-selection capability and from epoch serialization.


On layout lock re-acquisition, the client checks:
'''Prior art.''' CSDC added <code>LAYOUT_INTENT_CHANGE</code> for flag-only layout updates (<code>LCM_FL_INCOMPRESSIBLE</code>; EX-8355, Gerrit 54248). The EX patch defines the wire pieces (<code>LAYOUT_INTENT_CHANGE</code>, <code>LAIF_INCOMPRESSIBLE</code>, <code>OBD_CONNECT2_UPDATE_LAYOUT</code>) and an MDS handler (<code>lod_declare_layout_minor_change()</code>); master (<code>47638add78</code>) has only the wire definitions. That patch still bumps <code>layout_gen</code> and still tears down the client cache, avoiding the cost by deferring the change until no layout lock is held; IWM instead makes the client’s reacquisition cheap. The wire definitions could carry the IWM transitions. Existing client behaviour to build on: <code>lov_conf_set</code> already skips the prune when generation, flags and first-entry pattern are unchanged, and returns <code>-EBUSY</code> while <code>lo_active_ios &gt; 0</code>, deferring a layout change during active IO (<code>lov_object.c:1500-1515</code>).


* If the client’s current <code>layout_gen</code> is exactly N-1 and the new layout carries <code>STATE_ONLY_CHANGE</code>: update flags (and add new sub-objects for component instantiation) in the existing <code>lov_stripe_md</code> in-place, '''skip <code>cl_object_prune()</code>'''. The page cache survives.
'''Mapping revocation reuses existing code.''' <code>vvp_conf_set(OBJECT_CONF_INVALIDATE)</code> already calls <code>unmap_mapping_range(inode-&gt;i_mapping, 0, OBD_OBJECT_EOF, 0)</code> (source (<code>lustre/llite/vvp_object.c:110</code>)). It removes file-backed PTEs across processes, not VMAs or dirty page-cache contents, and private COW data is untouched; a later access faults again. Voluntary and idle AW release use the same primitive (directly or through a factored helper) without clearing a valid layout or pruning the cache; forced closure gets it from layout invalidation. Unmapping does not prevent refaults (Linux allows remapping races (Linux v6.12 <code>mm/memory.c:3556</code>)), so it is coupled with the local AW admission gate, the drain of admitted work and normal page/folio/PTE synchronization, so that no old writable mapping survives the final quiescence point; the final flush follows, under the old PW, object and AW protection. Write refaults go through normal LAYOUT → AW admission; a cached PW lock alone cannot restore write permission. No new VMA tracking or distributed lock is needed.
* Otherwise (flag absent, client is more than one version behind, or destructive change): full teardown as today. Fall back to old behavior.


The N-1 constraint is critical: the flag only describes the most recent transition. A client that missed multiple layout changes cannot trust it and must do the full rebuild.
'''Open items''' (<code>lustre-design-docs-od5.36</code>, <code>lustre-design-docs-iwm.3.3</code>): <code>__ll_page_mkwrite()</code> finishes its <code>cl_io</code> but leaves the page locked, and <code>ll_page_mkwrite()</code> returns <code>VM_FAULT_LOCKED</code> (Lustre fault path (<code>lustre/llite/llite_mmap.c:137</code>)); Linux (Linux v6.12 <code>mm/memory.c:5023</code>) installs the PTE afterwards, so an AW IO reference count does not cover that handoff. Integrate and test the locked-page/PTE interlock with AW cancellation (including a fault paused at that point, cached-PW refaults and continuously written shared mappings), and the <code>pcc_page_mkwrite()</code> early return. Implement the per-mirror prune in <code>STATE_ONLY_CHANGE</code>.


'''Safety properties:'''
<span id="client-implementation"></span>
== Client Implementation ==


* '''Old clients''' never see the flag (connect flag gates it) and always do full teardown. Correct by default.
'''Admission coverage.''' Every path that can dirty file data or modify OST objects must take AW admission (LAYOUT, AW reference, layout revalidation) before it does so, or be denied for IWM files. At <code>47638add78</code> the paths are:
* '''Stale clients''' (more than one version behind) ignore the flag and do full teardown. Correct.
* '''MDS reboot''' loses the transient flag. Clients reconnect without it and do full teardown. Correct.
* '''Flag absent for any reason''' (bug, race, mixed destructive/non-destructive sequence): full teardown. The optimization is purely opportunistic — absence never causes incorrectness.


'''Prior art: CSDC compressibility changes.''' The CSDC (client-side data compression) feature introduced <code>LAYOUT_INTENT_CHANGE</code> as a layout intent opcode for flag-only layout updates (EX-8355, Gerrit 54248). This allows the MDS to change the <code>LCM_FL_INCOMPRESSIBLE</code> flag on a layout without altering the stripe structure. The patch defines the wire protocol (<code>LAYOUT_INTENT_CHANGE</code> opcode, <code>LAIF_INCOMPRESSIBLE</code> flag, <code>OBD_CONNECT2_UPDATE_LAYOUT</code> connect flag) and the MDS-side handler (<code>lod_declare_layout_minor_change()</code>). However, the current implementation still bumps <code>layout_gen</code> and still triggers the full page cache teardown on the client — it avoids the problem by deferring the change until no layout lock is held, rather than making the client-side re-acquisition cheap. The IWM optimization extends this by making the client-side path aware of non-destructive changes, so the layout lock can be re-acquired without tearing down the <code>lov_object</code> or nuking the page cache. The <code>LAYOUT_INTENT_CHANGE</code> wire definitions from CSDC could be reused or extended to carry the IWM state transitions.
{| class="wikitable"
|-
! Path
! Notes
|-
| <code>ll_file_io_generic()</code> (<code>read_iter</code>/<code>write_iter</code>, AIO, io_uring, <code>splice_write</code>, <code>copy_file_range</code> via the splice fallback)
| The likely main admission hook
|-
| Tiny write (<code>ll_do_tiny_write()</code>, on by default)
| Writes into an already-dirty page with no <code>cl_io</code> and no new lock; the page is already under the epoch’s AW obligation, but the path must respect the voluntary-release admission gate
|-
| Hybrid IO
| Switches large buffered IO to DIO; DIO rules apply (client lock, [[#immediate-mirroring|§1.2]])
|-
| mmap write faults (<code>ll_page_mkwrite()</code>)
| Admission at fault entry; locked-page/PTE handoff in [[#layout-lock-and-page-cache-interaction|§2.2.7.3]]
|-
| PCC read/write/splice/mkwrite early returns
| Bypass Lustre’s IO stack; must not dirty an IWM file without admission
|-
| <code>cl_setattr_ost()</code> (truncate, time setattr) and <code>cl_falloc()</code>
| Admission like writes ([[#other-modifying-operations|§2.3.2]])
|}


'''Relationship to epoch handoff:''' Page cache preservation and epoch serialization are separate decisions. Legacy clients always use full teardown on layout refresh; destructive changes also require it. Non-destructive transitions may use <code>STATE_ONLY_CHANGE</code> for eligible IWM-aware clients, including flag-only epoch close. This does not remove the requirement to close the epoch before granting a usable layout to a non-IWM client, or to drain legacy writes before opening the next epoch (see [[#conflicting-operations-during-write-epochs-resync-old-clients|§2.4.6]]).
Fast read serves already-cached pages without <code>cl_io</code>; it is read-only and declines while the layout is invalidated, so it needs no AW. Verifying this coverage is tracked in <code>lustre-design-docs-od5.49.14</code> (mmap in <code>lustre-design-docs-od5.36</code>).
 
<span id="client-implementation"></span>
== Client Implementation ==


<span id="write-duplication-via-brw-page-fan-out"></span>
<span id="write-duplication-via-brw-page-fan-out"></span>
=== Write Duplication via BRW Page Fan-Out ===
=== Write Duplication via BRW Page Fan-Out ===


The client will need to duplicate all writes to all of the mirrors (non-write modifying operations are covered separately in [[#other-modifying-operations|§2.3.2]]). The key design choice is at what level duplication occurs.
The client duplicates every write to all participants (other modifying operations are in [[#other-modifying-operations|§2.3.2]]). The design choice is the layer at which duplication happens.


'''IO-level duplication is not viable.''' For buffered I/O, IO-level duplication would require sending pages to secondary mirrors at IO creation time — effectively forcing direct I/O to the secondaries — because the pages cannot be left in the page cache to be aggregated independently per mirror. This destroys asynchronous write semantics and write aggregation for secondary mirrors, which is a non-starter from a performance perspective. (For DIO, IO-level duplication would work but is not sufficient on its own.)
'''IO-level duplication is not viable.''' For buffered IO it would mean sending pages to the secondaries when the IO is created, effectively direct IO to the secondaries, because a page can be cached and aggregated for only one mirror. That loses asynchronous writes and write aggregation on every secondary. (For DIO it would work, but is not sufficient on its own.)


The selected approach operates at the BRW/RPC layer, intercepting after the primary mirror’s write has been fully prepared (pages assembled, bounce pages allocated for compression/encryption). This handles both buffered and direct I/O uniformly: for buffered writes, the page cache performs normal write aggregation on the primary mirror and duplication fires at BRW send time from the aggregated pages; for DIO, duplication fires from the same BRW page array built for the primary. In neither case does the duplication layer need to re-enter the IO start path.
'''BRW page fan-out (selected).''' Duplication happens at the BRW layer, after the primary’s write is fully prepared (pages assembled, bounce pages allocated), so buffered and direct IO are handled the same way: buffered writes aggregate normally in the primary’s page cache and are duplicated when the BRW is built, and DIO is duplicated from the same BRW page array. When the primary’s OSC has built its BRW, it calls up to LOV, which rebuilds each secondary’s RPCs from those pages through that mirror’s own stripe mapping, so mirrors may have different layouts (stripe count and size, components, compression). The call-up runs in the writeback/ptlrpcd work path, where sleeping is allowed, but it allocates and builds IO inside writeback, so memory use and lock dependencies there need care. Duplicating the primary’s RPCs verbatim (RPC duplication) is simpler but requires identical layouts on every mirror, which real deployments cannot guarantee ([[#combining-immediate-and-non-immediate-mirrors|§4.2]]).


'''BRW page fan-out''' (selected approach) re-derives per-mirror RPCs from the primary mirror’s assembled BRW page array via a call-up from the OSC layer to the LOV layer. When the primary mirror’s OSC has built its BRW RPC (pages assembled, bounce pages allocated), it calls up to the LOV layer, which fans out the page array to each secondary mirror’s OSC. Each secondary mirror’s RPCs are built through that mirror’s own stripe calculation, so mirrors can have different layouts (different stripe count/size). Note: this call-up path must be careful about the PTLRPC no-sleep requirement — the callback occurs in RPC submission context. This is more complex than simple RPC replay but is necessary to support heterogeneous mirror layouts, which real deployments require (see [[#combining-immediate-and-non-immediate-mirrors|Combining Immediate and Non-Immediate Mirrors]]). See Qian Yingjin’s prototype (LU-13643, Gerrit 63244) for a proof-of-concept of this approach.
Qian Yingjin’s prototype (LU-13643, Gerrit 63244) demonstrates the hook point and per-mirror rebuild, including heterogeneous layouts. It is a feasibility demonstration, not an implementation to reuse as-is (patchset 1, 2026-01, no human review, broad CI failures). Where it differs from this design:


An alternative considered was '''RPC duplication''', which sends the primary mirror’s RPCs to all mirrors identically. This is simpler but requires all mirrors to have identical layouts (same stripe count and stripe size), since the same RPCs — already addressed to specific OSTs at specific offsets — would be reused verbatim. This restriction is too limiting for real deployments, where mirror layouts may differ.
{| class="wikitable"
|-
! Prototype behaviour (Gerrit 63244 patchset 1)
! Required by this design
|-
| Primary BRW keeps its RPC slot until all secondaries finish (whole interpret deferred)
| Free the slot at the primary’s reply; defer only extent/page completion, bounce pages and error collection
|-
| Wraps the primary’s encrypted bounce pages as clear text for secondaries
| Reuse bounce pages when compression settings match; otherwise compress and encrypt from clear pages
|-
| Secondary errors dropped; only the primary’s result survives
| Collect per-mirror errors into the AW cancellation report (failed mirror IDs plus epoch ID)
|-
| Resend path can free or reuse the shared completion context
| Resend-safe refcounting of the shared completion
|-
| Every FLR write fans out; no IMMEDIATE/STALE gating
| Fan out only to the epoch’s participants
|-
| Secondaries write without grant once grant runs out
| Handle secondary ENOSPC/EDQUOT when the write is attempted, as a secondary error
|-
| Primary extent lock release does not wait for secondaries
| No primary extent lock release while a covering secondary attempt is unresolved, on every release path
|-
| Secondary DIO flag passed from the server-lock flag
| Secondary DIO RPCs carry the same <code>OBD_BRW_SYNC</code> semantics as the primary ([[#lock-lifetime-and-commit-requirements|§2.2.2]])
|-
| Secondary writes carry <code>OBD_BRW_NOLOCK</code> (no client or server lock)
| Acceptable given the per-mirror prune on read-eligibility loss ([[#layout-lock-and-page-cache-interaction|§2.2.7.3]])
|}


<span id="page-consistency-during-duplication"></span>
<span id="page-consistency-during-duplication"></span>
==== Page Consistency During Write Duplication ====
==== Page Consistency During Write Duplication ====


The primary’s BRW pages must remain valid and unmodified until every mirror attempt completes or terminates with outstanding activity safely resolved. There are two consistency threats, and the BIO and DIO paths handle them differently:
The primary’s BRW pages must stay valid and unmodified until every mirror attempt has completed or been safely resolved. Three threats:


* '''Source page modification:''' For buffered IO, another write to the same pages could modify them while secondary RPCs are in flight, causing the secondaries to receive different data than the primary — silent inconsistency. For DIO, modifying the userspace source buffer during a write is already an application error; the implementation must retain the source references needed by every mirror attempt, without assuming that a BRW page array inherently provides private copies.
* '''Source page modification.''' For buffered IO, another write to the same pages while secondary RPCs are in flight would send the secondaries different data from the primary. For DIO, changing the user buffer during a write is already an application error, but every mirror attempt must keep its own references to the source; a BRW page array is not a private copy.
* '''Competing IO on the same client:''' A second write to overlapping byte ranges must be serialized against in-flight duplication to prevent the secondary mirrors from seeing a different write order than the primary.
* '''Competing IO on the same client.''' An overlapping write must be serialized against duplication in flight, so the secondaries see the same order as the primary.
* '''Competing IO from other clients (ordering invariant).''' A primary extent lock is never released while any secondary attempt covering its pages is unresolved. Otherwise client A could release its lock with a secondary write still in flight, client B could take the lock and write the same range to every mirror, and B’s secondary write could land first: the primary ends up A-then-B and the secondary B-then-A, with no error recorded. This holds on every release path (blocking-AST cancellation, LRU cancellation, early lock cancellation, and DIO paths that drop their lock early).


The following two sections describe how each IO path addresses these threats.
Every mirror RPC for a page must complete or reach a resolved terminal failure, so outcomes are tracked per page or per RPC across mirrors. A successful reply still needs the release barrier’s durability check. A failed mirror owes no successful reply, but its failure is recorded and its IO can no longer affect buffer lifetime or later epochs and resync.


A third requirement follows from write duplication: each mirror RPC for a page must complete or reach terminal failure with outstanding activity safely resolved. Track per-page (or per-RPC) outcomes across mirrors; successful acknowledgments still require the separate durability checks before AW release. A failed mirror does not owe a successful acknowledgment, but its failure must be recorded and its IO must no longer threaten buffer lifetime or later epoch/resync ordering.
'''RPC slots and completion are separate.''' The primary BRW releases its RPC slot when its own reply arrives, as today. Only extent and page completion (ending writeback), bounce-page release and per-mirror error collection wait for every mirror. Holding the slot until the secondaries finish could deadlock: two OSTs, each primary for some files and secondary for others, could fill their slots with primaries waiting on secondaries queued behind them. With the split, a secondary RPC waits only for its own reply. The request stays on the import replay list until committed, independently of the slot. Keeping the primary extent in its in-RPC state until all mirrors finish preserves the ordering invariant. In-flight duplicated data is bounded by kernel writeback throttling and each OSC’s own slot limit.


<span id="buffered-io-completion-semantics"></span>
<span id="buffered-io-completion-semantics"></span>
==== Buffered IO Completion Semantics ====
==== Buffered IO Completion Semantics ====


Ordinary buffered <code>write()</code> retains normal asynchronous behavior: it may return after accepting data into the client cache, before any mirror RPC is sent. It does not wait for mirror writeback or promise stable storage. Normal write aggregation remains in place; synchronous requests have the separate contract in [[#operation-completion-consistency-model|§4.1]].
Buffered <code>write()</code> stays asynchronous: it may return once data is in the client cache, before any mirror RPC is sent, with no stable-storage promise and normal aggregation; explicit synchronization has its own contract ([[#operation-completion-consistency-model|§4.1]]). AW coverage starts at admission and continues at file level through syscall return, delayed writeback and commit tracking; reusing a cached PW lock obeys the same rule, and a layout refresh never leaves accepted data without an epoch.
 
AW coverage begins with the primary PW extent write-lock authority, before pages can be dirtied, and continues across syscall return, delayed writeback, and required commit/error tracking. Reusing a cached PW lock must obey the same coverage rule as acquiring one. A layout refresh cannot leave already accepted data without an epoch owner.


The fan-out barrier applies to writeback completion, not ordinary buffered syscall return. Preserve source-page stability and overlapping-write ordering until every mirror attempt has completed or safely terminated, not just the primary. Extend page/writeback completion accounting accordingly, including protection against mmap modification; a page lock alone must not be assumed to stop stores through an already writable mapping. Once no attempt can use the source data, page/buffer completion is distinct from durable commit: the epoch’s commit/error tracking and AW obligation continue until their own release conditions hold.
The fan-out barrier applies to writeback completion, not syscall return: page stability and overlapping-write ordering are kept until every mirror attempt is resolved, including against stores through already-writable mappings (a page lock alone does not stop those). Page completion is separate from durable commit, which the release barrier handles.


Memory-mapped writes use the same writeback fan-out, but acquiring write permission is an earlier event. The write-fault / <code>page_mkwrite</code> path must establish AW-covered PW authority before permitting dirtying. AW flush/writeback must also stop subsequent stores through existing writable mappings, by revoking or write-protecting them with appropriate race handling. How PW cancellation, mapping invalidation, and layout reconfiguration supply this barrier remains an explicit implementation discussion, not a reason to defer AW until writeback.
Memory-mapped writes use the same writeback fan-out, but write permission is granted earlier: the write fault validates the layout and takes AW at entry. Forced closure invalidates the layout and unmaps writable mappings before requesting AW cancellation; voluntary release unmaps with the local admission gate ([[#layout-lock-and-page-cache-interaction|§2.2.7.3]]).


<span id="direct-io-completion-semantics"></span>
<span id="direct-io-completion-semantics"></span>
==== Direct IO Completion Semantics ====
==== Direct IO Completion Semantics ====


DIO does not use the page cache, so page locks are not available for serialization. V1 waits for all mirror RPCs to complete or reach terminal failure, including safe resolution of outstanding activity, before returning and releasing buffer/ordering protection. Both modes preserve this rule: slow-fail can delay DIO during retry/recovery, while fast-fail abandons a secondary sooner. The return code reflects the primary result; secondary failures are recorded for the cancellation LVB (see [[#fast-fail-and-secondary-mirror-failure-policy|Secondary Failure Policy]]). Returning while secondary work continues is a separate future asynchronous-completion design requiring protected data copies and ordering against competing IO.
DIO has no page cache, so page locks cannot serialize it. In v1 a DIO waits for every mirror RPC to complete or reach a resolved terminal failure before returning and releasing its buffer and ordering protection, under all three secondary policies (wait-indefinitely has no timeout). The return code is the primary’s; secondary failures go into the cancellation report. A successful DIO is durable on the primary, and every participant’s updates are committed or recorded as failed, regardless of <code>O_SYNC</code>/<code>O_DSYNC</code> ([[#lock-lifetime-and-commit-requirements|§2.2.2]]); it does not wait for other writers or for the epoch to close. Returning while secondary work continues would need protected data copies and ordering against later IO, and is future work.
 
Successful DIO completion additionally guarantees stable storage for the successful primary write, regardless of <code>O_SYNC</code> or <code>O_DSYNC</code>. For the operation’s updates, every participating mirror must be committed or recorded as failed, with outstanding activity safely resolved. A secondary failure does not turn primary success into an application error. This guarantee is mandatory even if satisfying it requires explicit commit confirmation beyond the write RPC reply. It does not require all writers to release AW or the MDS to close the file-wide epoch.


<span id="implications-for-erasure-coding"></span>
<span id="implications-for-erasure-coding"></span>
==== Implications for Erasure Coding ====
==== Implications for Erasure Coding ====


The BRW page fan-out and page consistency mechanisms developed for IWM are intended to carry over to immediate EC writes, though significant design work remains. EC requires absolute consistency between source data pages and computed parity pages — primary locking provides ordering, and the tree lock (rounded to raidset-aligned extents) would ensure source pages are stable during parity computation. Tentatively, EC parity pages would be dispatched to their target OSTs via the same LOV-level RPC callback used for IWM secondary mirror fan-out, reusing the completion barrier infrastructure. However, the details of parity computation timing, partial-stripe writes, and the interaction between EC raidset geometry and the fan-out path remain to be resolved.
The BRW page fan-out and page consistency mechanisms are intended to carry over to immediate EC writes, though significant design work remains. EC requires absolute consistency between source data pages and computed parity pages — primary locking provides ordering, and the tree lock (rounded to raidset-aligned extents) would ensure source pages are stable during parity computation. Tentatively, EC parity pages would be dispatched to their target OSTs via the same LOV-level call-up used for IWM secondary mirror fan-out, reusing the completion barrier infrastructure. Parity computation timing, partial-stripe writes, and the interaction between EC raidset geometry and the fan-out path remain to be designed.


<span id="compression-and-encryption-across-mirrors"></span>
<span id="compression-and-encryption-across-mirrors"></span>
==== Compression and Encryption Across Mirrors ====
==== Compression and Encryption Across Mirrors ====


Ideally we would not have to compress or encrypt data more than once when sending to multiple servers Each secondary mirror’s OSC compresses independently from the original (uncompressed) source pages. Since compression is per-component in the Lustre layout (<code>lcme_compr_type</code>, <code>lcme_compr_chunk_log_bits</code> in <code>lov_comp_md_entry_v1</code>), different mirrors can independently specify compression settings — different algorithms, chunk sizes, or one compressed and one not — and the BRW page fan-out path handles this naturally. The compression work is performed once per mirror, but this is an acceptable cost for IWM’s typical 2-3 mirrors. Different chunk sizes across mirrors pose no correctness issue: if a write is not aligned to the secondary mirror’s chunk boundary, the secondary’s OST handles the read-modify-write internally, as it does for any unaligned compressed write.
Ideally we would not have to compress or encrypt data more than once when sending to multiple servers Compression is per component in the layout (<code>lcme_compr_type</code>, <code>lcme_compr_chunk_lum_bits</code>), so mirrors can differ in algorithm and chunk size, or be compressed and uncompressed. Because fan-out rebuilds each secondary’s RPCs through its own layout, each secondary’s OSC compresses with its own settings and heterogeneous compression needs no special protocol. One requirement follows from how client-side compression (CSDC) works: chunk alignment is done by the client at IO start, not by the OST. For an unaligned write, <code>lov_io_init</code> computes the leading and trailing partial chunks, the client reads them into cache, and <code>vvp_io_rw_lock</code> rounds the extent lock to whole chunks (TLC tree, CSDC series). That rounding uses the selected mirror’s chunk size today; for IWM it must use the largest chunk size among the participants. Chunk sizes are powers of two, so aligning to the largest aligns to all, and CSDC already computes that maximum across layout entries (<code>ci_max_compr_chunk_bits</code>). The cache then holds whole chunks for every mirror, and no secondary needs a read-modify-write inside writeback.


A possible future enhancement is bounce page reuse: when all immediate mirrors share identical compression settings, bounce pages built for the primary mirror could be reused for secondaries, avoiding redundant compression/encryption work. This is not planned for v1.
'''Bounce-page reuse.''' Fan-out runs after the primary has built its bounce pages. Encryption IVs come from the logical file block, not the OST object (<code>osc_request.c:1416-1418</code> at <code>47638add78</code>), so encrypted pages are the same for every mirror. A secondary whose compression settings match the primary’s, or where neither is compressed, reuses the primary’s bounce pages as-is; one with different settings compresses and then encrypts from the clear pages itself. Compression work is therefore done once per distinct setting.


<span id="other-modifying-operations"></span>
<span id="other-modifying-operations"></span>
=== Other Modifying Operations ===
=== Other Modifying Operations ===


The preceding section covers write IO duplication via BRW page fan-out — a mechanism specific to data writes, where the page cache and write aggregation require fan-out at the RPC assembly layer. Other modifying operations — truncate, hole punch, fallocate (space preallocation), and certain setattr variants — also modify OST object state and must be applied to all immediate mirrors. These operations do not transfer data pages, so the BRW fan-out path does not apply. Instead, they use a different fan-out mechanism at the LOV sub-io level.
Truncate, hole punch, fallocate and some setattr variants also modify OST objects and must be applied to every participant. They carry no data pages, so they fan out at the LOV sub-io level instead of through BRW fan-out.


<span id="operation-classification"></span>
<span id="operation-classification"></span>
==== Operation Classification ====
==== Operation Classification ====


Modifying operations fall into three categories based on their mirror interaction:
'''Duplicated to every participant (OST object operations):'''
 
'''OST object operations (must be duplicated to all mirrors):'''
 
* '''Truncate''' — reduces file size via <code>OST_PUNCH</code> over [new_size, EOF). The LOV maps this to the affected stripes, as with any ranged operation.
* '''Fallocate''' — covers both space preallocation (mode 0) and hole punch (<code>FALLOC_FL_PUNCH_HOLE</code>). Preallocation changes the physical allocation map without writing data; punch deallocates a byte range, replacing data with a hole. Both use <code>OST_FALLOCATE</code> with the appropriate mode flags.
* '''Time setattr''' (<code>ATTR_MTIME</code>, <code>ATTR_ATIME</code>, <code>ATTR_CTIME</code>) — sets timestamps on OST objects via <code>osc_setattr_async</code>. Lightweight, no range calculation.


'''MDS-only operations (no mirror interaction needed):'''
* '''Truncate''' — <code>OST_PUNCH</code> over _size, EOF), mapped to the affected stripes of each mirror. <code>ll_setattr_raw()</code> first sends an MDS RPC (the MDS approves the size change, including <code>ETXTBSY</code>), then <code>cl_setattr_ost()</code> performs the OST side; only the OST side is duplicated.
* '''Fallocate''' — preallocation (mode 0, <code>KEEP_SIZE</code>), hole punch (<code>FALLOC_FL_PUNCH_HOLE</code>) and <code>ZERO_RANGE</code> (<code>ll_fallocate()</code> supports these; other modes return <code>-EOPNOTSUPP</code>), all through <code>OST_FALLOCATE</code>.
* '''Timestamp setattr''' (<code>ATTR_MTIME</code>, <code>ATTR_ATIME</code>, <code>ATTR_CTIME</code>) — via <code>osc_setattr_async</code>.


* '''chmod, chown''' — pure MDS inode metadata. OSTs are not contacted. No IWM concern.
'''Not duplicated by the client:'''
* '''setstripe''' — layout changes via MDS ioctl. Not a modifying operation in the IWM sense; layout changes interact with IWM through layout/epoch coordination, not through operation duplication.


'''Mixed operations:'''
* '''chmod''' stays on the MDS. '''chown, chgrp and project ID''' are pushed by the MDT itself to every stripe of every instantiated component of every mirror, including STALE ones, asynchronously through the OSP llog, for quota accounting (<code>LA_REMOTE_ATTR_SET</code>, <code>lod_attr_set</code> at <code>47638add78</code>). That already covers all mirrors and changes only ownership metadata, so no client fan-out or epoch interaction is needed.
* '''setstripe''' and other layout changes interact with IWM through layout and epoch coordination, not duplication.


* '''Truncate''' is technically mixed: <code>ll_setattr_raw</code> always sends an MDS RPC first (the MDS must approve the size change and check <code>ETXTBUSY</code>), then dispatches <code>cl_setattr_ost</code> for the OST-side truncation. The MDS phase is not mirror-specific; only the OST phase requires duplication.
'''FIEMAP''' is a query, not a modifying operation: for IWM files it reports the primary mirror’s allocation only, takes no AW and does not close the epoch. Existing FLR FIEMAP ([source (<code>lustre/lov/lov_object.c:2148</code>)) iterates components across all mirrors, so IWM must select the primary explicitly; <code>FIEMAP_FLAG_SYNC</code> keeps its normal writeback behaviour (source (<code>lustre/llite/file.c:3250</code>)). Non-IWM FLR behaviour is unchanged (implementation: <code>lustre-design-docs-iwm.10.1</code>).


<span id="lov-sub-io-fan-out"></span>
<span id="lov-sub-io-fan-out"></span>
==== LOV Sub-IO Fan-Out ====
==== LOV Sub-IO Fan-Out ====


In current FLR, truncate, punch, and fallocate go through the <code>cl_io</code> <code>CIT_SETATTR</code> path. The LOV layer selects a single mirror (<code>lov_io_mirror_init</code> sets <code>lis_mirror_index</code>), creates sub-ios only for that mirror’s stripes, and sends the write intent RPC to stale other mirrors on the MDS. The operation executes on one mirror; resync later copies data to bring stale mirrors up to date.
In FLR today these operations use the <code>CIT_SETATTR</code> path: LOV selects one mirror (<code>lov_io_mirror_init</code> sets <code>lis_mirror_index</code>), creates sub-ios only for its stripes, and a write intent stales the other mirrors; resync copies the result later.
 
For IWM, these operations execute on all eligible immediate participants in real time. Clean non-immediate copies are excluded, with their affected components marked STALE through the MDS layout/write-intent path before the modifying IO is permitted. The fan-out point is <code>lov_io_iter_init</code>: instead of iterating only the primary mirror’s stripes, it creates sub-ios for every eligible immediate mirror’s stripes that overlap the operation’s byte range. Each sub-io calls the appropriate per-stripe RPC (<code>osc_punch_send</code> for truncate, <code>osc_fallocate_base</code> for punch/fallocate, <code>osc_setattr_async</code> for time setattr). This is analogous to BRW page fan-out for writes — the LOV is the mirror-aware layer that manages per-mirror dispatch — but at the sub-io level rather than the RPC assembly level, because these operations have no page data to fan out.


The existing <code>lov_foreach_io_layout_mirror</code> macro already accepts a mirror index parameter. For IWM, the iteration expands to cover all eligible mirrors. Per-stripe size and offset translation (<code>lov_size_to_stripe</code>) uses each mirror’s own component layout, so heterogeneous mirror layouts (different stripe count/size) work correctly — the same file-level truncation offset maps to different OST object offsets depending on the mirror’s geometry.
For IWM they execute on every participant immediately. Clean non-immediate mirrors are excluded, with their affected components marked STALE through the MDS write intent before the operation runs. The fan-out point is <code>lov_io_iter_init</code>: it creates sub-ios for the overlapping stripes of every participant, each calling the per-stripe RPC (<code>osc_punch_send</code> for truncate, <code>osc_fallocate_base</code> for fallocate and punch, <code>osc_setattr_async</code> for timestamps). Today <code>lov_foreach_io_layout(ind, lio, ext)</code> takes the mirror implicitly from <code>lio-&gt;lis_mirror_index</code> (via <code>lov_io_layout_at()</code>); for IWM the iteration covers every participant. Offsets are translated per mirror (<code>lov_size_to_stripe</code>), so heterogeneous layouts map the same file offset to different object offsets correctly. Since the parameters are known when the <code>cl_io</code> starts, all sub-ios are created up front, with no call-up from OSC.
 
The BRW fan-out for writes uses an OSC-to-LOV callback — a concession to the fact that write data must pass through the page cache and BRW assembly before the LOV can replicate it. These operations have no such constraint. The operation parameters (byte range, mode flags) are known at <code>cl_io</code> init time, so the LOV can create all per-mirror sub-ios up front during <code>iter_init</code>. This is a cleaner fan-out pattern: no callbacks, no PTLRPC no-sleep concerns, just parallel sub-io dispatch.


<span id="aw-lock-and-epoch-interaction"></span>
<span id="aw-lock-and-epoch-interaction"></span>
==== AW Lock and Epoch Interaction ====
==== AW Lock and Epoch Interaction ====


These operations participate in AW lock epochs identically to writes. The client acquires the AW CW lock before the operation (or reuses a cached one), the MDS transitions the layout to WRITE_PENDING on first acquisition, and the operation executes under the epoch. Per-mirror errors are collected and reported via the AW lock cancellation LVB on release — if truncate succeeds on mirror 0 but fails on mirror 1, mirror 1 has the wrong (larger) size and is marked STALE on epoch close. Resync later copies the correct state from the primary.
These operations are admitted exactly like writes (layout, then AW reference, then layout revalidation) and run inside the epoch. Per-mirror errors go into the cancellation report: if a truncate succeeds on the primary but fails on a secondary, that secondary keeps the wrong size and becomes STALE at close, and resync copies the primary.


For IWM-aware clients, the write intent RPC that FLR uses today (<code>LAYOUT_INTENT_TRUNC</code>, <code>LAYOUT_INTENT_WRITE</code> for fallocate) could potentially be subsumed by the AW lock epoch mechanism in a future optimization. Such an optimization must preserve the MDS-side staling of affected non-immediate components before any operation that omits them; duplicating to immediate mirrors alone is insufficient. The MDS must also continue to handle write intents from non-IWM clients (see [[#conflicting-operations-during-write-epochs-resync-old-clients|§2.4.6]]), so the write intent code path cannot be removed. For v1, both paths coexist.
Before a truncate or punch, the client flushes dirty pages in the affected range; under IWM that flush goes through BRW fan-out, so earlier buffered writes reach every mirror before the truncate sub-ios run.


'''Page cache flush ordering:''' Before truncate or punch, the client flushes dirty pages in the affected range. Under IWM, this flush triggers BRW fan-out to all mirrors (via the write duplication path in [[#write-duplication-via-brw-page-fan-out|§2.3.1]]), ensuring all mirrors have consistent data up to the truncation point before the truncate sub-ios execute. The interaction between this flush and AW lock dirty tracking — specifically, how the client knows which dirty pages belong to the current epoch and must be flushed — is an open design question (see [[#operation-completion-consistency-model|§4.1]]).
FLR’s write intents (<code>LAYOUT_INTENT_TRUNC</code>, <code>LAYOUT_INTENT_WRITE</code> for fallocate) remain in v1, both for component instantiation and staling of non-immediate copies and for non-IWM clients where they are allowed (optional primary-only writes, [[#conflicting-operations-during-write-epochs-resync-old-clients|§2.4.6]]). Folding them into AW admission is a possible future optimization, but it would still have to stale omitted non-immediate components before the operation.
 
'''Open item (A7):''' the exact range-flush ordering, completion and error contract for truncate, punch and fallocate ([[#operation-completion-consistency-model|§4.1]]).


<span id="implications-for-erasure-coding-1"></span>
<span id="implications-for-erasure-coding-1"></span>
Line 386: Line 541:


<span id="fast-fail-and-secondary-mirror-failure-policy"></span>
<span id="fast-fail-and-secondary-mirror-failure-policy"></span>
==== Fast-fail and Slow-fail Secondary Policies ====
==== Secondary Timing Policies and Administrative Abandonment ====


'''V1 supports two switchable policies for secondary failures.''' Fast-fail is the first development milestone because predictable failure reporting is needed for testing and availability-sensitive use cases. Slow-fail follows in the development sequence and is also required for the first supported release: many deployments need normal Lustre retry/recovery persistence to avoid unnecessary full-mirror resyncs. Development order does not select the production default.
'''Three secondary IO policies,''' selected per client mount (not per file or in the layout), with '''finite slow-fail as the default''':


# '''Fast-fail:''' Use the non-delay failure path for secondary operations and promptly treat an unrecoverable attempt as a terminal mirror failure, without entering extended secondary retry/recovery waits. Existing <code>ci_ndelay</code> behavior is the starting point, but the complete secondary path must be checked: setting that flag alone must not leave hidden reconnect, recovery, replay, or commit/sync waits that defeat the policy. A transient outage may therefore stale the mirror and require resync.
# '''Wait indefinitely:''' keep retrying and recovering with no timeout. Outstanding work can survive an arbitrarily long outage; an unrecoverable error or administrative abandonment still ends it.
# '''Slow-fail:''' Use the ordinary Lustre RPC retry, reconnect, recovery, and replay procedures, retaining their normal opportunity to complete the operation and preserve the mirror. These procedures must also have a defined give-up path for IWM secondaries. When the configured give-up condition is reached, or a terminal error is returned, stop retrying, resolve outstanding activity safely, and report the mirror failed. Existing waits that can otherwise continue forever must remain terminable through this policy.
# '''Slow-fail:''' a longer finite budget; when it expires, safely resolve outstanding activity and record the secondary failed.
# '''Fast-fail:''' the same model with shorter timings; it gives up sooner on a transient outage, staling the mirror and requiring resync.


'''Slow-fail must be able to fail.''' The implementation needs a configurable give-up policy, such as an overall recovery/time budget or an explicit abort mechanism, that can reach the underlying waits. A per-RPC timeout is insufficient if retries or recovery repeatedly restart it. The exact controls, scope, production default, and any automatic timeout/retry budget remain to be selected; v1 must demonstrate that an unavailable secondary can be abandoned without waiting for it to return. This applies during normal IO and forced epoch closure, including secondary commit confirmation and <code>OST_SYNC</code>. Slow-fail may wait substantially longer than fast-fail, but cannot offer an irrevocable wait-forever path as its only behavior.
All three share the same durability, terminal-failure and safe-resolution rules; slow and fast differ only in timing. Different mounts may choose differently without changing shared mirror-state rules. A finite budget must reach every underlying wait: submission, reconnect, recovery and replay, the modifying RPC, and commit/sync including <code>OST_SYNC</code> during forced closure. Per-RPC timeouts that restart indefinitely do not bound it. Expiry starts terminal resolution; it does not promise that the syscall or fencing finishes by then. Fast-fail is the first development milestone, for predictable fault-injection tests; slow-fail is required for the first supported release.


'''Shared failure semantics:''' A retryable event is not yet a final mirror error if slow-fail subsequently completes the operation and its durability requirements. An error becomes terminal when the selected policy gives up or normal processing returns a terminal failure; this can occur during submission, transport/recovery, the modifying RPC, or commit/sync. Once terminal, the mirror’s error remains recorded for the epoch, and the client promptly begins resolution and AW release/reporting without the idle-cache delay. The MDS closes the epoch and marks the failed mirror STALE. Late success from one RPC cannot clear the epoch’s accumulated mirror failure.
'''Terminal errors.''' A retryable event is not a mirror error if recovery completes the operation and its durability. An error becomes terminal when a finite budget expires, normal processing returns a terminal failure, or an administrator abandons the secondary, at any stage (submission, transport, the RPC, or commit/sync). It then stays recorded for the epoch, and the client promptly resolves its IO and releases AW with the report; the MDS closes the epoch and marks the mirror STALE. A late success of one RPC cannot clear the epoch’s mirror failure.


'''Safe abandonment:''' A timeout or error bit alone does not make outstanding IO safe. The implementation must stop retries and safely complete, cancel, or fence activity that could still access source buffers or interfere with later epoch IO or resync. Buffer and ordering protection must remain until that condition is met. Both modes enforce the same AW release invariant: every participating mirror’s updates are committed or recorded as failed, and outstanding activity has been safely resolved. Failure policy does not weaken the durability requirement for mirrors reported clean.
'''Safe resolution.''' A timeout, administrative request or error bit does not by itself make outstanding IO safe: retries must be stopped and activity completed, cancelled or fenced so that it can no longer touch source buffers or interfere with later epochs or resync. Buffer and ordering protection stays until then. No policy weakens the durability required of mirrors reported clean.


'''Application visibility and completion:''' Secondary failure does not replace a successful primary result with an application error, including for DIO, <code>fsync()</code>, <code>fdatasync()</code>, <code>O_SYNC</code>, and <code>O_DSYNC</code>. Both modes retain the v1 completion barriers: DIO and explicit durability requests require committed-or-recorded-failed outcomes for their scope, while ordinary buffered <code>write()</code> remains asynchronous and retains the required page/writeback protection during later fan-out. Slow-fail can therefore stall DIO, durability requests, writeback, or epoch close while ordinary recovery proceeds. Fast-fail reduces secondary-induced waiting; it is not an overall syscall deadline. Neither policy requires application completion to wait for global epoch closure. The primary remains the ordering anchor and follows its separate normal failure/restart path (see [[#primary-mirror-write-failure|Primary Mirror Write Failure]]).
'''Space and quota.''' Secondary ENOSPC/EDQUOT is handled when the secondary write is attempted, as a secondary error (whole-mirror STALE, primary result unaffected). The primary’s grant and buffering are unchanged, and there is no advance reservation on the secondaries in v1. Implementation: <code>lustre-design-docs-od5.49.8</code>; advance reservation as a later improvement: <code>lustre-design-docs-od5.56</code>.


'''Configuration changes:''' Switching policy must not clear accumulated errors, release protection around outstanding IO, or strand operations in an uninterruptible recovery wait. The implementation must define when a changed policy takes effect, including how an already-waiting secondary reaches the configured give-up path. These controls govern IWM secondary behavior without silently changing ordinary primary IO policy.
'''Application results.''' A secondary failure never turns a successful primary result into an application error, for DIO, <code>fsync()</code>, <code>fdatasync()</code>, <code>O_SYNC</code> or <code>O_DSYNC</code>. All policies keep the completion barriers: DIO and explicit durability requests need committed-or-recorded-failed outcomes for their scope, and buffered writes keep page protection during fan-out. Slow-fail can stall DIO, sync requests, writeback or epoch close longer than fast-fail; wait-indefinitely can stall them without limit. None waits for epoch close. Primary failures follow the separate primary rules ([[#primary-mirror-write-failure|§2.4.4]]).


'''Degradation and resync:''' A terminal secondary failure reduces redundancy while a successful primary remains usable. The MDS reports failed mirrors through the existing changelog path, and userspace resync restores them using normal FLR recovery. Slow-fail trades longer waits for a chance to avoid this degradation; fast-fail accepts it sooner.
'''Changing the policy''' affects new IO only; operations already waiting keep their policy and timings, and are not woken or converted. A change never clears accumulated errors or releases outstanding protection, and does not alter primary IO behaviour.


'''Deferred alternatives:''' Returning from DIO while secondary retries continue, or queueing replication after operation completion, are separate future designs. They require protected source snapshots and ordering against subsequent modifications. The v1 slow-fail mode performs standard retry/recovery while retaining the existing completion and protection rules.
'''Administrative abandonment''' is part of declaring an OST dead: a client-side action targeting that OST that reaches its outstanding secondary work, including wait-indefinitely IO, invocable on one client or triggered on all clients when an OST is declared dead cluster-wide. It is not a per-file protocol or a policy change. Affected files then follow the normal terminal-error and epoch-close rules.
 
Terminal secondary failures reduce redundancy while the primary stays usable; failed mirrors appear in the changelog and are restored by normal FLR resync. Slow-fail trades longer waits for a chance to avoid that; fast-fail accepts it sooner. Returning from DIO while secondary retries continue, or replicating after completion, would need protected source copies and ordering against later writes, and are future designs.
 
'''Open items''' (<code>lustre-design-docs-od5.33</code>): numeric timings, timer start and accounting, control names, and the policy sampling point for new IO; the abandonment interface and target identity, distribution and acknowledgement, and how disconnected, reconnecting and new clients learn of it (sending a notification is not proof of cluster-wide completion); the dead-OST state’s lifetime, fencing and reactivation, so abandoned work cannot resume against resync or new epochs; an OST that also holds primary objects (primary failures must not be hidden as secondary-only); delivery milestone.


<span id="failure-handling"></span>
<span id="failure-handling"></span>
Line 413: Line 573:
=== Mirror States: STALE and INFLIGHT Flags ===
=== Mirror States: STALE and INFLIGHT Flags ===


During an IWM write epoch, participating immediate secondary mirrors are marked INFLIGHT. The primary (write leader) is not flagged — it remains readable. Non-immediate mirrors do not participate in that epoch and follow the ordinary FLR staling rule below. The AW lock tracks that a write epoch is active.
At epoch entry (the first AW request), the MDT selects the primary by the usual policy, moves the file to WRITE_PENDING and sets INFLIGHT on every other participant. Entry does not clear pre-existing STALE or make an unsynchronized copy eligible, and non-immediate mirrors are not marked INFLIGHT. The AW lock tracks that the epoch is active.


'''INFLIGHT''' — indicates an IWM write epoch is actively in progress on this mirror. This is a new flag. A mirror marked INFLIGHT is being updated by immediate writes and is expected to become consistent when the write epoch closes. IWM-aware clients exclude it from reads until closure. Unknown component flags are not a compatibility barrier: existing non-IWM clients can ignore INFLIGHT during ordinary IO.
'''INFLIGHT''' means temporary exclusion from reads during an epoch, not that some RPC is outstanding. It stays set between writes until the epoch closes, when mirrors that were synchronized at entry are expected to be consistent again. IWM clients exclude INFLIGHT mirrors explicitly, and patched 2.18/master and 2.15 clients exclude them as unknown mirror-state flags. Unpatched clients ignore unknown flags (layout verification checks only sizes and offsets; selection tests only STALE, PREF_RD, INIT and PARITY; <code>lov_ea.c:441-470</code>, <code>lov_object.c:689, 721</code> at <code>47638add78</code>), which is why the MDT refuses their opens ([[#conflicting-operations-during-write-epochs-resync-old-clients|§2.4.6]]).


'''STALE''' — the mirror is an unknown amount of out of sync with the primary. Its contents should not be used and the issue can only be resolved with a full resync. This is the same flag used by delayed write mirroring (FLR today). For participating immediate mirrors, normal IWM writing uses INFLIGHT, not STALE; errors or eviction may leave them STALE at epoch close. Outside an IWM epoch, a legacy write intent uses ordinary delayed-write FLR rules and marks affected secondary components STALE. Old clients understand STALE and can trigger resyncs.
'''STALE''' means the mirror is out of sync by an unknown amount; its contents must not be used until a full resync. It is the same flag delayed-write FLR uses. Participants use INFLIGHT during normal writing, and become STALE only through errors or client loss at close. Pre-existing STALE survives entry and close until resync. With the optional primary-only write setting, every non-primary mirror, IMMEDIATE ones included, becomes STALE before the write is admitted.


'''Clean non-immediate mirrors:''' IMMEDIATE describes write participation, not whether a copy is synchronized. A non-immediate mirror may be clean after resync or after removal from the immediate set. Before permitting a write or other modifying IO that omits that mirror, the MDS must mark its affected components STALE under ordinary FLR rules, including when the modification is performed by IWM clients on other mirrors. It cannot assume all non-immediate mirrors are already stale. See [[#combining-immediate-and-non-immediate-mirrors|Tiering Transitions]].
'''Clean non-immediate mirrors.''' IMMEDIATE describes write participation, not synchronization: a non-immediate mirror may be clean after a resync or after removal from the immediate set. Before admitting any modifying IO that omits such a mirror, including IO by IWM clients on the participants, the MDS marks its affected components STALE under ordinary FLR rules ([[#combining-immediate-and-non-immediate-mirrors|§4.2]]).


'''Backward compatibility:''' Neither IMMEDIATE nor INFLIGHT can be relied upon to make non-IWM clients reject a mirror. Safety comes from MDS-controlled layout handoff: revoke legacy layout grants before opening an IWM epoch, and close that epoch before granting a usable layout to a non-IWM client. Closure returns the file to RDONLY without staling healthy mirrors merely to allow a legacy read; errors and eviction still follow the close rules. Subsequent modifications follow the participation and staling rules above. See [[#conflicting-operations-during-write-epochs-resync-old-clients|§2.4.6]].
Participants’ flags are settled at epoch close ([[#epoch-close-mds-processing|§2.2.4]]), which is separate from staling omitted non-immediate components before IO. STALE mirrors are visible to lamigo through changelogs ([[#replacing-mirrorspermanent-failure|§2.4.5]]).
 
The MDS resolves participating immediate mirrors’ epoch flags at epoch close — see [[#epoch-close-mds-processing|Epoch Close]] for the full rules. This is separate from marking omitted non-immediate components STALE before modifying IO. Mirrors left STALE after a write failure are visible to lamigo via changelogs, triggering recovery (see [[#replacing-mirrorspermanent-failure|§2.4.5]]).


<span id="writeupdate-error-from-client"></span>
<span id="writeupdate-error-from-client"></span>
=== Write/Update Error from Client ===
=== Write/Update Error from Client ===


Clients associate errors from all modifying operations with the AW epoch. Secondary retryable events follow the selected fast-fail or slow-fail policy; a successfully recovered transient event need not stale a mirror. A terminal error at submission, transport/recovery, modifying-RPC completion, or commit/sync time is recorded as a mirror failure. Reporting is not restricted to commit-time failures. The client then resolves remaining IO and releases AW without the normal idle-cache delay, carrying the accumulated error summary to the MDS and triggering epoch closure. See [[#fast-fail-and-secondary-mirror-failure-policy|Secondary Failure Policy]] for give-up and safe-abandonment requirements.
Clients associate errors from every modifying operation with the epoch. Retryable secondary events follow the mount’s secondary policy; one that recovers does not stale a mirror. A terminal error at any stage (submission, transport or recovery, the RPC, or commit/sync), or administrative abandonment, is recorded as a mirror failure. The client then resolves its remaining IO and releases AW without waiting for the retention timer, reporting the error and triggering closure ([[#fast-fail-and-secondary-mirror-failure-policy|§2.3.3.1]]).


<span id="client-eviction-from-ost"></span>
<span id="client-eviction-from-ost"></span>
==== Client Eviction from OST ====
==== Client Eviction from OST ====


A client eviction from an OST can invalidate the affected mirror’s completeness and commit guarantees; it is not necessarily observed only at commit time. The client reports the mirror error to the MDT when it cancels AW, after safely resolving outstanding IO. If consistency cannot be established, epoch close clears INFLIGHT and sets STALE, requiring later resync by lamigo or other userspace tooling. Selecting slow-fail does not turn an eviction-induced consistency failure into a clean result merely because connectivity returns.
Eviction from an OST can invalidate that mirror’s completeness and commit guarantees at any point, not only at commit. The client reports the mirror failed in its AW cancellation, after safely resolving its outstanding IO, and the mirror becomes STALE at close, for resync by lamigo or other tools. Regaining the connection later does not make an eviction-damaged mirror clean, whatever the policy.


<span id="client-eviction-from-mdt"></span>
<span id="client-eviction-from-mdt"></span>
=== Client Eviction from MDT ===
=== Client Eviction from MDT ===


If a client with an active AW lock is evicted from the MDT, we cannot know whether it completed writes successfully. The MDS force-flushes the evicted client’s caches via OST extent locks (see [[#flush-semantics-on-lock-release|Flush Semantics]]) to maximise the chance that in-flight writes complete on the primary mirror, then closes the epoch with all non-primary mirrors marked STALE (clearing INFLIGHT, setting STALE). The flush only benefits the primary’s completeness — secondaries are staled regardless.
If a client holding AW is evicted from the MDT, its outcomes are unknown. The ordered close drains and flushes the responsive writers, not the evicted one. The outcome follows the lost-participant rule ([[#epoch-close-mds-processing|§2.2.4]]): resolve a known primary error first, then every mirror other than the resulting primary becomes STALE in full, regardless of reported error detail, with the primary kept as a best-effort copy that may lack some of the lost client’s writes. Late writes from the evicted client are fenced by the FLR era-ending version push, not an extent-lock flush; sequencing is open in <code>lustre-design-docs-iwm.6.1</code>.


<span id="client-loss"></span>
<span id="client-loss"></span>
==== Client Loss ====
==== Client Loss ====


Client loss - where a client crashes or similar - is the same as MDT eviction, since such a client is evicted from the MDT and OSTs, and the MDT eviction takes priority over the OST evictions. All mirrors except the primary will have INFLIGHT cleared and STALE set.
A client that crashes or otherwise disappears is evicted from both the MDT and the OSTs; the MDT eviction governs, so this is handled exactly as above.


<span id="mds-failover"></span>
<span id="mds-failover"></span>
==== MDS Failover ====
==== MDS Failover ====


When the MDS crashes, AW lock state is lost. On recovery, the MDS uses its durable active-epoch FID set (see [[#recovery-after-mds-failover|Recovery After MDS Failover]]) to identify files with active epochs. If all clients reconnect, AW locks are replayed and epochs resume normally. If any client is evicted, eviction proceeds as in [[#client-eviction-from-mdt|Client Eviction from MDT]] — the only difference is how the MDS discovers which files have active epochs: in-memory lock state (live) vs the durable FID set (recovery).
On MDS restart, durable tracking identifies open epochs, participants and reported errors; after ordinary lock replay and reconciliation, affected epochs are rolled up with the ordinary close rules, and any participant that did not recover is a lost participant on its own files only. See [[#recovery-after-mds-failover|Recovery After MDS Failover]].


<span id="primary-mirror-write-failure"></span>
<span id="primary-mirror-write-failure"></span>
=== Primary Mirror Write Failure ===
=== Primary Mirror Write Failure ===


In case of a write failure to the primary mirror, the clients will attempt to complete all other writes and inform the MDS as usual. The MDS closes the epoch normally (see [[#epoch-close-mds-processing|Epoch Close]]): the failed primary is marked STALE, and secondaries that completed without error have INFLIGHT cleared (they are now clean and in sync). The next IWM epoch selects a new primary from eligible non-stale IMMEDIATE mirrors — the MDS selects it by examining the write statuses from active writers, finding a mirror with no errors. A clean non-immediate copy, including demoted flash, is not eligible without conditional promotion. If all mirrors had write failures, all mirrors are marked STALE and the file degrades to its pre-IWM state, requiring a full resync — the same outcome as a total write failure in delayed-write FLR today. See [[#client-loss|Client Loss]] in this section for what to do if a client is lost entirely.
If writes to the primary fail, clients still complete the other mirrors’ writes and report as usual, and the MDS closes the epoch normally ([[#epoch-close-mds-processing|§2.2.4]]). It selects a replacement primary from the eligible participants with no known error (from collected reports and durable error records, by the usual policy) and marks the failed old primary STALE. This happens before any lost-participant rule, which is then evaluated against the new primary. A clean non-immediate copy, such as demoted flash, is not eligible without conditional promotion. The outcome is part of the atomic closing transaction.


This should work transparently with minimal changes.
'''When no replacement is eligible''' (every candidate failed or was already STALE): report the errors through the usual logging and IO error paths, keep the epoch’s existing primary as the sole read-eligible copy, clear INFLIGHT and mark every other mirror STALE; a previously STALE secondary is never rehabilitated. This is best effort: it does not mean the primary is complete or that the failed writes succeeded, and normal access may still hit IO errors. Resync uses the primary’s current contents to restore redundancy but cannot recover missing updates. No new degraded flag or administrator source selection is involved. The rationale is to match the existing best-effort behaviour for ordinary write errors without IWM. The same outcome applies in live closure and recovery.


<span id="replacing-mirrorspermanent-failure"></span>
<span id="replacing-mirrorspermanent-failure"></span>
=== Replacing Mirrors/Permanent Failure ===
=== Replacing Mirrors/Permanent Failure ===


When a mirror is left STALE after a write failure, lamigo needs to be notified to attempt resync. The existing mechanism is changelogs — lamigo already watches changelogs for files with STALE mirrors as part of Hot Pools, so IWM does not require a new notification path. The changelog overhead is modest (5-10% for metadata-intensive workloads, less for IO-intensive). If resync fails repeatedly (e.g., due to permanent OST failure), lamigo could add a new replacement mirror; the failed mirror may be removed later.
When a mirror is left STALE after a write failure, lamigo is notified through changelogs, which it already watches for STALE mirrors as part of Hot Pools; no new notification path is needed. Changelog overhead is modest (5-10% for metadata-intensive workloads, less for IO-intensive ones). If resync keeps failing, for example because an OST has failed permanently, lamigo can add a replacement mirror (<code>lfs mirror extend</code>) and the failed mirror can be removed later. No special commands are needed.


These operations require quiescing active writers. They follow the administrative path and its flush dependency requirement (see [[#aw-lock-vs-layout-lock-interaction|§2.2.7]]): resync uses layout EX (via <code>LL_LEASE_RESYNC</code>), while MDS AW EX closes the epoch and forces writers to flush. Their sequencing must not block layout refresh needed to finish that flush. lamigo does not need any special commands — it uses the normal mirror add (<code>lfs mirror extend</code>) and resync (<code>lfs mirror resync</code>) operations. After the operation completes, both locks are released and clients can resume writing.
These operations quiesce writers through the administrative LAYOUT EX → AW EX path ([[#aw-lock-vs-layout-lock-interaction|§2.2.7]]). Resync takes a lease (<code>LL_LEASE_RESYNC</code>) and its resync request takes LAYOUT EX on the MDT; AW EX then closes any open epoch, draining writers under their existing protection. If that epoch, or an earlier one, lost a participant, resync confirms the era-ending version fence on the source mirror’s objects before copying ([[#epoch-close-mds-processing|§2.2.4]]). Resync instantiates the components it needs; if that keeps failing, it fails and leaves the mirror STALE.


This means writers will stall during mirror replacement or resync, but they will not fail — they simply block on AW lock acquisition until the operation completes. Since these are separate commands (first add the mirror, then resync it), there will be two quiesce windows. This is acceptable since mirror replacement is an infrequent recovery operation, not a steady-state path.
New writers stall on layout acquisition (and AW where applicable) until the operation completes; that is not a write failure. Adding a mirror and resyncing it are separate commands, so there are two quiesce windows, acceptable for an infrequent recovery operation.


<span id="conflicting-operations-during-write-epochs-resync-old-clients"></span>
<span id="conflicting-operations-during-write-epochs-resync-old-clients"></span>
=== Conflicting Operations During Write Epochs (Resync, Old Clients) ===
=== Conflicting Operations During Write Epochs (Resync, Old Clients) ===


Clients that understand FLR (delayed-write mirroring, 2.11+) but do not support IWM need explicit handling during active write epochs. These clients can open mirrored files (<code>exp_connect_flr()</code> passes) but lack <code>OBD_CONNECT2_FLR_IMMED_MIRROR</code> and cannot participate in write duplication. Pre-2.11 clients (pre-FLR) are blocked at file open and are not a concern.
'''Compatibility policy:'''
 
# '''Clients we can modify (2.18/master and 2.15)''' treat an unknown mirror-state flag as making the mirror unusable, as if STALE, so a client that does not understand INFLIGHT skips the mirror rather than reading an inconsistent copy. They advertise this with a new connect capability, distinct from <code>OBD_CONNECT2_FLR_IMMED_MIRROR</code> (full IWM support; already reserved at <code>lustre_idl.h:855</code>). This is a local selection rule: the client never writes STALE into the layout or clears unknown flags. Full IWM clients advertise the capability too.
# '''Clients without the capability''' cannot open a file that has any IMMEDIATE mirror, whatever the file’s state (RDONLY, WRITE_PENDING, with or without AW holders). Existing pre-FLR exclusion is unchanged.


'''The problem:''' Non-IWM clients can ignore unknown <code>lcme_flags</code>, including IMMEDIATE and INFLIGHT, during ordinary IO. INFLIGHT alone therefore does not stop them reading partially updated secondary mirrors. Checking only write intents is also insufficient: an old client given an already-writable WRITE_PENDING layout may write without another intent when the relevant secondary components are already STALE. It holds no AW lock, so those writes escape epoch accounting.
'''Known flags.''' A skip-unknown client must treat IMMEDIATE as known membership metadata (which does not imply AW support), or the rule would also exclude the primary. The mask covers mirror-state flags only, not arbitrary component flags or layout formats. INFLIGHT may stay unknown and be skipped; a client that recognizes it must exclude it the same way. Unknown state may not be ignored in read selection, size and attribute queries, or any fallback path that would silently pick an excluded mirror.


'''Design: Close on layout acquisition; stale on write intent.''' The compatibility boundary is the grant of a usable layout, not just a request explicitly identified as a write. Apply this policy on every path granting or returning a usable layout to a non-IWM client, including <code>LAYOUT_INTENT_ACCESS</code>, layouts supplied on open, and revalidation after invalidation or recovery:
'''Non-open access by clients without the capability.''' Data access needs an open, so the open denial covers all reads and writes. Metadata paths without an open (<code>stat</code>/<code>ls -l</code> via glimpse, getxattr, <code>lfs getstripe</code>) stay allowed. During an epoch such a client may glimpse an INFLIGHT secondary or fold it into its size (the maximum over non-STALE components of all mirrors, <code>lov_attr_get_composite</code>) and briefly report a size slightly behind or ahead of the primary. That is transient, limited to the epoch, exposes no data, and is accepted. Withholding the layout (which would break <code>stat</code>) and per-client layout rewriting were rejected; old clients are not a design target.


# Check <code>exp_connect_immed_mirror()</code> on the requesting export. If an IWM epoch is active, hold the legacy request and serialize the handoff against new epoch admission. Use active-epoch state, not merely the presence of INFLIGHT on a secondary.
'''Capable clients without full IWM support.''' They read the primary and never force epoch closure just to read. Writes are denied by default. An optional setting allows primary-only writes through the existing FLR write-intent sequence: any open epoch is closed with LAYOUT EX → AW EX, the MDT changes the layout and marks every non-primary mirror (IMMEDIATE ones included) STALE, then the primary-only write proceeds under ordinary FLR layout, extent-lock and resync serialization. That deliberately gives up immediate redundancy until resync; it is not participation in an IWM epoch, and a later clean IWM epoch never clears that STALE. The setting never admits clients without the capability.
# Force epoch closure through AW: stop admitting new participants, flush existing writers, collect cancellation LVB error reports, and handle eviction. Complete the normal close transition to RDONLY, clearing INFLIGHT and setting STALE only where the close rules require it. Healthy mirrors remain clean.
# For an access/read request, grant the completed RDONLY layout. This is the actual serialized file state, not a client-specific RDONLY view of a still-active or writable layout. The old client may read any clean mirror. RDONLY forces it to send a write intent if it subsequently wants to modify the file.
# On that write intent, apply ordinary delayed-write FLR: select the write primary, transition RDONLY → WRITE_PENDING, and mark affected secondary components STALE under existing FLR rules. The old client writes only the primary, without AW. If the original request was already a write intent, process this transition immediately after epoch closure; no artificial RDONLY round trip is required.


A legacy reader pays for epoch closure but does not itself reduce redundancy or create a resync requirement. A legacy writer is allowed outside the epoch and degrades affected mirrors as delayed-write FLR does today. When no IWM epoch is active, existing delayed-write state is handled normally; an ongoing legacy WRITE_PENDING session is not reset to RDONLY on every acquisition.
'''Promoting IMMEDIATE on an open file''' is a migration-style protected operation, not a raw flag change: it reuses existing migration quiescence, layout invalidation and revalidation, and serialized publication; closes any open epoch with LAYOUT EX → AW EX; and keeps exclusion through the conditional clean-copy and membership checks ([[#combining-immediate-and-non-immediate-mirrors|§4.2]]) until publication. Promotion never clears STALE, and needs no data copy or temporary inode when the intended copies are already clean. Ordinary migration contention, retry and failure handling apply.


'''Returning to IWM:''' Before opening an epoch, revoke previously granted legacy layouts, including RDONLY layouts held by readers, so old clients cannot continue accessing secondaries during the epoch. Verify that old reads already in flight cannot return secondary data from an invalidated view without the required restart/revalidation; layout revocation is not itself an IO drain. If delayed-write access was granted, also drain or fence outstanding legacy writes before IWM writes begin. Layout invalidation alone is not a flush or durability barrier, and legacy writers hold no AW lock. This handoff must cover dirty cached data, in-flight IO, and retries under the old writable layout. Mirrors staled by legacy writes remain STALE until resync: a new epoch must not make them clean or count them as synchronized participants. Once these requirements are met, eligible clean immediate mirrors can participate in a new epoch. A later legacy layout acquisition closes that epoch again.
'''Existing descriptors are not grandfathered.''' Migration preserves file identity and open handles, so applications are not forced to reopen. Supported holders continue through normal layout refresh and AW admission. Holders without the capability must not obtain or use the promoted layout through an old descriptor, cached layout, PW or mmap authority, or reconnect and replay: the capability gate is enforced on layout return and refresh and on IO admission, not only at open. This denies unsupported access; it does not destroy descriptors or evict clients.


'''Implementation constraint:''' Serialize both directions: prevent epoch reopening between close and legacy layout grant, and prevent legacy grants racing epoch entry. Existing AW holders must still be able to finish flushes, including layout refreshes or IO restarts. An unconditional layout EX → AW EX wait is insufficient if AW cancellation requires layout CR. The concrete serialization and legacy-write drain/fence mechanism remains to be specified; see [[#aw-lock-vs-layout-lock-interaction|§2.2.7]].
Migration precedents at <code>47638add78</code>: blocking migration (<code>lustre/utils/lfs.c:1085</code>) takes a group lock and swaps layouts after a data-version check; nonblocking migration (<code>lustre/utils/lfs.c:1608</code>) uses a lease and an atomic swap at close; the MDT swap handler (<code>lustre/mdt/mdt_handler.c:1839</code>) takes LAYOUT/XATTR EX. These are precedents, not proof: stock migration provides neither the capability gate nor the AW drain, the blocking path takes its group lock before MDT LAYOUT EX (not automatically compatible with the AW order), and the swap handler checks layout support only for the requester.


Legacy clients use the existing full layout refresh/teardown path. Eligible IWM-aware clients may preserve their page cache for non-destructive flag changes as described in [[#layout-lock-and-page-cache-interaction|§2.2.7.3]]; that optimization does not change the handoff or durability requirements.
Resync closes any open epoch first, whatever the client’s capability, then follows normal resync serialization ([[#replacing-mirrorspermanent-failure|§2.4.5]]); an ordinary read needs neither.


'''Sustained mixed-client access:''' Both legacy reads and writes can cause repeated epoch closures and layout refresh costs. Only legacy writes inherently stale healthy secondary components and require later resync. Old clients are excluded from active IWM epochs, not from writing IWM-configured files altogether. Mixed-version operation remains supported under delayed-write semantics between epochs, subject to the handoff rules above.
'''Deferred: state-conditional access for clients without the capability.''' Allowing them to open files only between epochs would not make their activity vanish when the next epoch opens: they could keep descriptors, layouts, cached PW or mmap authority, dirty data, RPCs and retries without any AW lock, and revoking a soft layout is not a writeback or durability barrier. It would need a server-driven drain and fence mechanism that has not been designed.


Resync also closes any active epoch first, regardless of client capability, then proceeds through normal resync serialization (see [[#replacing-mirrorspermanent-failure|Replacing Mirrors/Permanent Failure]]). This differs from an ordinary legacy read, which needs no resync and does not stale healthy mirrors.
'''Open items''' (<code>lustre-design-docs-od5.40</code>, <code>lustre-design-docs-iwm.1</code>, checklist P4): the capability’s wire bit and the mirror-state mask; enforcement points and denial errors across open, create-with-layout, layout return and refresh, reconnect and replay, serialized with layout and membership changes; invalidation of reads already in flight at entry; the primary-only write setting’s name, scope and enforcement; for promotion, the group/lease/AW placement, draining legacy PW, mmap, dirty data and IO (including holders and older clients without layout-invalidation support), and making sure migration exclusion never blocks admitted IO or the cancellation flush; partial-component mirror handling.


<span id="future-enhancements-and-alternatives"></span>
<span id="future-enhancements-and-alternatives"></span>
Line 498: Line 656:
== Cancellation LVB Format and Future Stripe Detail ==
== Cancellation LVB Format and Future Stripe Detail ==


'''Decision:''' Build the extensible message structure in v1, initially carrying only mirror-level errors. V1 neither collects nor transmits stripe error bitmaps. A future extension can add per-OST-object detail for finer repair and immediate EC, while retaining the same mandatory mirror summary and conservative fallback. Per-stripe degradation and repair semantics remain future work.
The v1 cancellation report carries mirror-level errors only, but uses an extensible structure from the start, so that per-OST-object detail can be added later for finer repair and immediate EC without changing the mandatory summary or the conservative whole-mirror fallback.
 
'''Message structure from v1:'''
 
* A versioned header with explicit header and total-message lengths, sufficient to locate the mandatory summary and any following extensions.
* A mandatory counted mirror-error bitmap, associated with the cancelling AW lock and its epoch. Bit <code>i</code> denotes participant <code>i</code> in the epoch’s shared immediate-mirror list, ordered by mirror ID. A bit is not a raw mirror ID. Membership changes require an epoch boundary, so ordinary component/layout changes must not silently renumber this list.
* An optional sequence of typed, length-delimited detail records. V1 sends no such records. Their framing is defined from the start so a receiver can skip unknown optional detail within a supported message version and still process the mandatory mirror summary.
 
The exact packed field widths, alignment, and negotiated message limits are to be specified with the cancellation RPC implementation. The structural decision is settled: v1 is not a bare two-byte message, and future detail is not implied merely by a set mirror-error bit. The v1 receiver validates lengths, counts, and the epoch/mirror mapping before combining reports. An absent or invalid mandatory report must not be interpreted as a successful zero-error report; it follows conservative recovery handling. An unsupported top-level version is not covered by the rule for skipping optional records.


'''V1 semantics and compatibility:''' Any set mirror-error bit is sufficient to mark that mirror STALE. The MDS ORs valid reports using the shared mirror mapping. A receiver that does not understand optional stripe detail still treats every mirror indicated by the summary as failed as a whole. A future detail-aware receiver may refine repair only when it has complete, valid detail from all relevant error reports; a mirror-level-only report requires whole-mirror fallback. Missing or unsupported detail can never make a mirror with a reported error appear clean.
'''Message structure (v1):'''


'''Future stripe numbering: one flat index space per mirror.''' Components are ordered within a mirror. Concatenate their stripe lists in that order and number the resulting OST-object slots from zero; numbering does not restart at each component. If the ordered components have stripe counts <code>n0, n1, ...</code>, local stripe <code>s</code> in component <code>k</code> has flat index <code>n0 + ... + n(k-1) + s</code>. For example, two components with 2 and 3 stripes occupy indices <code>0–1</code> and <code>2–4</code>. This is an object-slot index space, not a file-byte address or a count of repeated stripe-sized data chunks. Components without OST-object slots contribute no entries.
* A versioned header with explicit header and total-message lengths, enough to find the mandatory summary and any extensions, plus the epoch ID (the opening layout generation; [[#lock-semantics|§2.2.1]]).
* A mandatory count followed by that many failed mirror IDs (16-bit, as in <code>lcme_id</code>). A count of zero is a clean report. Membership changes need an epoch boundary, so IDs keep their meaning for the whole epoch.
* An optional sequence of typed, length-delimited detail records. V1 sends none, but the framing exists so a receiver can skip unknown optional records within a supported version and still process the summary. An unsupported top-level version is not covered by that skipping rule.


A future detail record identifies the mirror, the layout mapping that defines its flat index space, the total number of slots, and the error bitmap. Component IDs do not have to accompany every reported stripe: both sides derive the component/local-stripe pair from the ordered layout. Heterogeneous component geometries are handled by summing the actual per-component counts.
The receiver validates lengths, the count, the epoch ID, and that each ID is a participant of that epoch, then takes the union of all valid reports; any reported mirror becomes STALE as a whole. A missing or invalid report is never treated as a clean one; it gets conservative handling. Field widths, alignment and message limits are specified with the RPC implementation. The current LDLM_CANCEL request carries no LVB; adding the field, associating reports with the right lock in batched and early cancellations, and packing and validation are v1 protocol work.


'''Layout changes during an epoch:''' Flat indices are interpreted against the layout mapping under which the affected IO was issued. Since an epoch can span layout changes, the client and MDS must preserve that mapping or translate recorded errors before combining detail from different layouts; old bits must never be interpreted using a silently renumbered current layout. If precise translation is unavailable, retain the mirror-error summary and fall back to whole-mirror handling. This requirement does not force epoch closure for ordinary component instantiation. The exact mapping lifetime and detail aggregation mechanism can be designed with the future stripe-reporting extension; v1 needs only the stable mirror mapping.
'''Sizing.''' The summary is a count plus 2 bytes per failed mirror, bounded by the mirror count (<code>LUSTRE_MIRROR_COUNT_DEF</code> 16, <code>LUSTRE_MIRROR_COUNT_MAX</code> 256); a clean report carries only the count.


'''Sizing:''' The mandatory bitmap for <code>M</code> immediate participants uses <code>ceil(M / 8)</code> bytes before header and alignment overhead: 2 bytes for 16 participants or 32 for the current maximum of 256 (<code>LUSTRE_MIRROR_COUNT_DEF</code> and <code>LUSTRE_MIRROR_COUNT_MAX</code>). This is bitmap payload size, not the total cancellation report size.
'''Future per-stripe detail.''' Per-stripe error recording is TBD, including how failures are collected, stored durably, aggregated across layout versions, replayed and consumed; v1 durable tracking records failed mirrors only. A future detail record would identify the mirror, the layout mapping that defines its index space, the number of slots, and an error bitmap, under these rules:


A future stripe bitmap for <code>N</code> flattened OST-object slots uses <code>ceil(N / 8)</code> bytes before record overhead and alignment. For 200, 500, and 2000 slots, the raw bitmap sizes are 25, 63, and 250 bytes respectively. The final wire size also includes the mandatory summary, header, and each detail record’s metadata and padding.
* '''One flat index space per mirror.''' Concatenate the stripe lists of the mirror’s components in order and number the OST-object slots from zero: with stripe counts <code>n0, n1, ...</code>, local stripe <code>s</code> of component <code>k</code> has index <code>n0 + ... + n(k-1) + s</code> (two components of 2 and 3 stripes occupy <code>0–1</code> and <code>2–4</code>). Components without OST objects contribute nothing. This counts object slots, not file bytes.
* '''Layout changes.''' Indices are interpreted against the layout under which the IO was issued. Since an epoch can span layout changes, client and MDS must keep or translate that mapping before combining detail; without a precise translation, fall back to the whole-mirror summary. This does not force epoch closure for component instantiation.
* '''Merging.''' A detail-aware receiver may refine repair only with complete, valid detail from every relevant report; missing or unsupported detail never makes a reported mirror look clean.
* '''Size.''' A bitmap for <code>N</code> slots is <code>ceil(N / 8)</code> bytes before record overhead (25, 63 and 250 bytes for 200, 500 and 2000 slots). <code>LOV_MAX_STRIPE_COUNT</code> (2000) limits one plain component, not a mirror’s total, so 16 × 2000 is not a realizable worst case; the RPC implementation must derive and enforce a real bound, falling back to the mirror summary when detail does not fit.


<code>LOV_MAX_STRIPE_COUNT</code> (2000) limits a plain component’s stripe count; composite allocation also accounts for space used by other components in the shared layout xattr. It is not an independent allowance of 2000 stripes for every mirror or a bound on the sum of a mirror’s component stripe counts. Consequently, multiplying 16 mirrors by 2000 stripes does not establish a realizable worst-case report. A maximum must be derived from supported composite layouts and the chosen record encoding, including any multiple mappings retained across an epoch. The RPC implementation must define and enforce that bound, with conservative mirror-level fallback when precise optional detail cannot be supplied within it.
Per-extent reporting (byte ranges of failed writes within a stripe) is a further level whose size grows with the number of disjoint writes; it is not part of v1 or the per-object extension.
 
'''Transport:''' The report travels from client to MDS in the cancellation request. Current LDLM_CANCEL has no LVB field. Lustre’s variable-sized RPC field machinery can support the new message, but adding the field, associating reports with the correct lock in batched cancellation, and packing/validating it are v1 protocol work. The report’s explicit lengths support extension; they are not a claim that the existing cancel path already transports this data.
 
'''Per-extent reporting (deferred):''' Byte ranges of failed writes within a stripe are a separate possible level of detail. Their representation can grow with the number of disjoint modifications during sustained failures, whereas an object bitmap remains bounded by the reported layout mapping. Range tracking and its repair semantics are not part of v1 or the proposed per-object extension.


<span id="write-ordering-alternatives"></span>
<span id="write-ordering-alternatives"></span>
Line 540: Line 691:
Open questions for this approach include: whether to apply this only to overlapping operations (which requires tracking the extent of all data-modifying operations through a write epoch, but avoids false positives from non-overlapping writes), and recovery scenarios when an OST fails mid-epoch and the checksum chain is incomplete.
Open questions for this approach include: whether to apply this only to overlapping operations (which requires tracking the extent of all data-modifying operations through a write epoch, but avoids false positives from non-overlapping writes), and recovery scenarios when an OST fails mid-epoch and the checksum chain is incomplete.


A related but heavier-weight idea is having OSTs maintain full write commit vectors (ordered lists of committed write extents) and comparing them across mirrors at epoch close. This subsumes chained checksums but requires significantly more per-OST state and MDS-side comparison logic.
A related but heavier-weight idea is having OSTs maintain full write commit vectors (ordered lists of committed write extents) and comparing them across mirrors at epoch close. This subsumes chained checksums but requires significantly more per-OST state and MDS-side comparison logic. # Policies and Open Items {#open-problems}
 
<span id="open-problems"></span>
= Open Problems =


<span id="operation-completion-consistency-model"></span>
<span id="operation-completion-consistency-model"></span>
== Operation Completion Consistency Model ==
== Operation Completion Consistency Model ==


The application policies below are agreed. The remaining open work is their implementation and integration with the epoch/layout protocol, not a choice between primary-only and all-mirror syscall completion.
Application return, RPC completion, durable commit, AW release and epoch closure are distinct events. The v1 contracts:


{| class="wikitable"
{| class="wikitable"
Line 556: Line 704:
|-
|-
| Ordinary buffered <code>write()</code>
| Ordinary buffered <code>write()</code>
| May return after data is accepted into the client cache, before mirror RPC submission. Normal asynchronous aggregation; no stable-storage promise at this boundary.
| May return once data is in the client cache, before any mirror RPC. Normal asynchronous aggregation; no stable-storage promise.
|-
|-
| Buffered dirtying / mmap write permission
| Buffered dirtying / mmap write permission
| Covered by AW from acquisition/use of the primary PW write authority, not first at writeback. Dirty data remains the epoch’s responsibility across syscall return and layout refresh.
| Each modifying IO takes AW at admission, after layout validation and before PW use or dirtying, not at writeback. Dirty data stays the epoch’s responsibility across syscall return and layout refresh.
|-
|-
| Mirror RPC / writeback completion
| Mirror RPC / writeback completion
| Every attempt has completed or safely terminated before its source buffers and write-ordering protection can be released. An acknowledgment is not by itself proof of durable commit.
| Every mirror attempt has completed or been safely resolved before source buffers and ordering protection are released. A reply is not proof of durable commit.
|-
|-
| DIO completion
| DIO completion
| Stable storage is required without synchronous flags. Every participating mirror’s updates are committed or recorded as failed and outstanding activity is safely resolved. The primary determines the application result.
| Stable storage without synchronous flags: every participant’s updates committed or recorded as failed, outstanding activity resolved. The primary determines the result.
|-
|-
| <code>fsync()</code>, <code>fdatasync()</code>, <code>O_SYNC</code>, <code>O_DSYNC</code>
| <code>fsync()</code>, <code>fdatasync()</code>, <code>O_SYNC</code>, <code>O_DSYNC</code>
| For the operation’s required data/metadata scope, obtain committed-or-recorded-failed outcomes and safely resolve outstanding activity. Primary success remains success despite secondary failure.
| For the operation’s data/metadata scope, committed-or-recorded-failed outcomes with outstanding activity resolved. Primary success is success despite secondary failure.
|-
|-
| AW release
| AW release
| Quiesce the covered write authority and resolve all its dirty data and IO, including durability and accumulated failure reporting. This is not the same boundary as one operation completing.
| Drain admitted IO (including IO that has not dirtied yet), flush the whole file, and synchronously confirm durability on every mirror reported successful, with failures resolved and reported. No opt-out; never with dirty data remaining.
|}
|}


'''Explicit durability requests:''' Preserve the usual distinction between full data/metadata synchronization and data-integrity synchronization, and between file flushes and synchronous writes’ operation scope. Primary writeback/commit errors, relevant earlier buffered-write errors, and required metadata errors retain normal application error reporting. A secondary-only failure must not be surfaced as an error on a later <code>fsync()</code> when it would be invisible for the write itself. V1 offers no durable-redundancy success requirement; an explicitly requested stricter policy could be considered in the future, consistently across APIs.
'''Explicit durability requests''' keep the usual distinctions between full and data-integrity synchronization, and between file flushes and synchronous writes. Primary writeback and commit errors, relevant earlier buffered-write errors and required metadata errors are reported normally. A secondary-only failure is never surfaced on a later <code>fsync()</code> when it would be invisible for the write itself. V1 has no “durable redundancy” success requirement; an explicitly requested stricter policy could be added later, consistently across APIs.


'''No additional global-close barrier:''' Application completion does not require all clients to release AW, the file-wide epoch to close, or the MDS to publish the final INFLIGHT/STALE layout. A durability request must complete the work in its defined scope; it must not acquire file-wide AW EX merely to satisfy this contract or wait for unrelated writers to stop. AW may remain held for subsequent writes. Local commit/error tracking must survive operation completion until reported, and the durable active-epoch record plus conservative recovery must protect against loss of an unreported outcome. Secondary read eligibility still follows epoch-close rules; a successful <code>fsync()</code> does not itself make secondaries readable.
'''No global-close barrier.''' Completing an operation never requires other clients to release AW, the epoch to close, or the final layout to be published; a durability request does not take AW EX or wait for unrelated writers. AW may stay held for later writes. Local commit and error tracking survives operation completion until reported, and durable tracking with conservative recovery covers an outcome lost before reporting. A successful <code>fsync()</code> does not make secondaries readable; only epoch close does.
 
'''Remaining discussion and verification:''' The accompanying <code>iwm_discussion_checklist.md</code> is the shared agenda. P1–P8 combine PW lifetime, cancellation, mmap write permission, <code>cl_conf_set()</code>, layout/AW serialization, and epoch opening/closing/handoff into one protocol design discussion; see [[#aw-lock-vs-layout-lock-interaction|§2.2.7]]. A7 separately covers truncate, punch, and fallocate contracts. A9 covers DIO/backend commit confirmation, sync data/metadata and error-reporting scopes, and a finite local synchronization boundary under concurrent dirtying. The application policies above remain settled while those questions are answered.


<span id="combining-immediate-and-non-immediate-mirrors"></span>
<span id="combining-immediate-and-non-immediate-mirrors"></span>
== Combining Immediate and Non-Immediate Mirrors ==
== Combining Immediate and Non-Immediate Mirrors ==


Real deployments will combine immediate and non-immediate mirrors on the same file. For example, DDN’s hot pools feature places latent mirrors on HDD that are only populated once a file ages out of a flash tier — these mirrors are not written at write time but filled in later by resync (lamigo). A file might have two flash mirrors (written immediately for HA) and two HDD mirrors (synced lazily for capacity/archival).
Real deployments combine immediate and non-immediate mirrors on one file. For example, DDN’s Hot Pools places latent mirrors on HDD that are filled by resync (lamigo) once a file ages out of flash: a file might have two flash mirrors written immediately for availability and two HDD mirrors synced lazily for capacity.
 
The IMMEDIATE flag alone determines participation; no new “immediacy group” or file-level layout state is needed, and RDONLY → WRITE_PENDING → SYNC_PENDING stay as they are. Non-immediate mirrors are not necessarily STALE: resync can leave HDD clean before promotion, and demotion can leave flash clean but no longer immediate. Before any modifying IO that omits such a copy, its affected components are marked STALE ([[#mirror-states-stale-and-inflight-flags|§2.4.1]]), which is what makes conditional promotion safe: a copy that missed a write never still appears clean. Adding and removing IMMEDIATE are conditional layout operations at epoch boundaries that change who participates in later epochs, never the participants of a running one.
 
'''Tiering lifecycle''' (e.g. flash to HDD):
 
# The file starts with two immediate flash mirrors.
# HDD mirrors are added without IMMEDIATE; they need resync.
# When the file ages out:
## '''Resync HDD''' with ordinary resync. That makes the copies clean at completion, not afterwards.
## '''Conditionally promote HDD.''' Close any open epoch and resolve its outcomes; then, while excluding modifying IO and competing layout changes, check that the intended HDD copies still exist and are clean over the required extents, and set IMMEDIATE in the same serialized transition. A userspace check followed by an unconditional flag update is not enough. If a write staled the copies meanwhile, promotion fails and the caller resyncs and retries. Promotion never clears STALE.
## '''Keep both tiers immediate during the overlap.''' Later epochs write all healthy flash and HDD participants, so there is never a window without immediate copies; failures can still stale a participant.
## '''Conditionally demote flash.''' Close the overlap epoch and process its outcomes. Clear IMMEDIATE on the flash mirrors only if the retained immediate HDD copies still provide the clean coverage and redundancy required; an IMMEDIATE flag alone is not evidence of a healthy copy. Otherwise leave flash immediate. If flash was the primary, an eligible HDD primary is selected before the next epoch.
## '''Remove flash copies separately,''' after revalidating that the retained copies make removal safe; an earlier successful demotion does not authorize deletion regardless of later failures.
 
'''Serialization.''' Promotion and demotion take LAYOUT EX, then AW EX: invalidate old layout views, drain and flush writers, check conditions, and publish the new membership before admitting writes under it. AW EX alone would not suffice if clients could resume with an old fan-out. Clients that never held AW are handled by the capability gate and the migration-style promotion of open files ([[#conflicting-operations-during-write-epochs-resync-old-clients|§2.4.6]]).


The per-mirror IMMEDIATE flag determines membership in immediate write duplication and AW epoch tracking. Healthy immediate mirrors participate; STALE mirrors require resync before they can count as synchronized copies. Mirrors without IMMEDIATE use ordinary delayed-write FLR. No new “immediacy group” or file-level layout state is needed: the existing RDONLY → WRITE_PENDING → SYNC_PENDING states remain singular.
'''Progress.''' Copying, promotion, demotion and removal are separate operations, and stopping between them leaves a valid state (HDD copied but not promoted, both tiers immediate, or flash demoted but not removed). Continuous writes can re-stale HDD between resync and promotion indefinitely, so v1 gives no progress guarantee on a continuously written file; promotion needs a write-quiet interval. A future resync-and-promote operation could hold serialization through promotion (it would still need writers to pause).


Non-immediate mirrors are not necessarily STALE. Resync can leave HDD clean before promotion, and demotion can leave flash clean but no longer immediate. Before permitting modifying IO that excludes such a copy, its affected components must be marked STALE, as described in [[#mirror-states-stale-and-inflight-flags|Mirror States]]. This rule is essential to making conditional promotion safe: a copy that missed a write must not still appear clean.
'''Interaction with resync.''' Resync of a non-immediate mirror takes LAYOUT EX, then AW EX to close any open epoch, and keeps normal resync serialization through the copy; afterwards writers may resume and re-stale the copy before promotion, which is why promotion is conditional.


Adding and removing IMMEDIATE are separate, conditional layout operations at epoch boundaries. They change which mirrors participate in subsequent epochs, rather than changing the participant set underneath existing writers. The ordinary AW flush and error-reporting rules still apply.
'''Administrative redundancy guard.''' A demotion or removal that would leave fewer than two healthy IMMEDIATE copies requires an explicit force override. Removing or demoting a copy that is already STALE or otherwise not counted does not reduce healthy redundancy and needs no force. The count is taken after the ordered drain, rollup and primary processing, in the same serialized transition as the membership change, counting clean, valid coverage rather than IMMEDIATE flags. Force overrides only this guard: never locking, drain, error resolution, retained-copy validity or safe primary selection, and it cannot clear STALE. Demoting the last IMMEDIATE participant still requires a valid retained copy and resolution of the old AW obligations. The guard does not imply a two-copy minimum at creation and does not prevent failure-induced degradation.


The tiering lifecycle would work as follows (e.g., migrating from flash to HDD):
'''Open items''' (<code>lustre-design-docs-od5.44</code>, <code>lustre-design-docs-od5.40</code>): conditional promotion and demotion in the layout-management interface and tiering controller, with retries; the force override’s API and CLI encoding, denial errors and where it is enforced; tests of post-drain counts, cleanup of degraded files and force without weakening retained-copy validity.


# The file starts with two immediate flash mirrors.
<span id="heterogeneous-compression-across-mirrors-resolved"></span>
# Add HDD mirrors without IMMEDIATE; they initially need resync.
== Heterogeneous Compression Across Mirrors ==
# When the file ages out and should migrate to HDD:
## '''Resync HDD.''' Use ordinary resync to make the intended destination copies clean. This establishes synchronization at completion, not a guarantee that they remain synchronized after writes resume.
## '''Conditionally promote HDD.''' Close and resolve any active epoch, including its flush/error outcomes. While excluding new modifying IO and competing layout changes, verify that the intended HDD copies still exist and are clean over the required extents, then set IMMEDIATE in the same serialized layout transition. A userspace check followed by an unconditional flag update is insufficient. If an intervening write staled the copies, promotion fails and the caller must resync/retry. Promotion never clears STALE itself.
## '''Keep both tiers immediate during the overlap.''' Subsequent epochs duplicate writes to all healthy participating flash and HDD mirrors. This overlap is intentional; the sequence does not introduce a window with no immediate mirrors. Failures can still stale a participant under the normal IWM rules.
## '''Conditionally demote flash.''' Close the overlap epoch and process its actual flush/error results. Clear IMMEDIATE on the selected flash mirrors only if the retained immediate HDD copies still provide the clean coverage and redundancy required by policy. An IMMEDIATE bit alone is not evidence of a healthy destination. If the check fails, leave flash immediate. If flash was the primary, select an eligible HDD primary before the next epoch.
## '''Remove flash copies separately.''' Revalidate that the current retained copies make removal safe, then use the normal mirror-removal operation. Success of the earlier demotion is not a permanent authorization to delete a copy regardless of subsequent failures.


'''Serialization:''' Both promotion and demotion require epoch quiescence plus layout coordination: complete existing writers, invalidate obsolete layout views, check the relevant conditions, and publish the new membership before admitting writes under it. Apply the legacy-write drain/fence rules in [[#conflicting-operations-during-write-epochs-resync-old-clients|§2.4.6]] as well; AW cannot drain clients that never held it. AW EX alone is insufficient if clients can resume using an obsolete fan-out. The implementation must also satisfy the no-circular-wait requirement in [[#aw-lock-vs-layout-lock-interaction|§2.2.7]].
Heterogeneous compression across immediate mirrors needs no special protocol: each secondary compresses with its own settings when its RPCs are rebuilt, provided client-side chunk alignment uses the largest chunk size among participants. See [[#compression-and-encryption-across-mirrors|Compression and Encryption Across Mirrors]].


'''Separate operations and progress:''' Copying, promotion, demotion, and removal need not be one combined operation. If the controller stops between stages, the file remains in a valid state: HDD is copied but not promoted (and may become stale again), both tiers are immediate, or flash is demoted but not deleted. Continuous writes can, however, repeatedly stale HDD between resync and promotion, preventing promotion indefinitely. The separated v1 operations provide no progress guarantee under continuous writes; retries alone do not guarantee completion. Progress then requires a sufficient write-quiescent interval. A future resync-and-promote operation could retain serialization through promotion to close this gap, but is not part of this v1 sequence and would still need writers to quiesce. It would not need to combine promotion with flash demotion.
<span id="open-items-summary"></span>
== Open Items Summary ==


'''Interaction with resync:''' Resync of a non-immediate mirror first closes any active epoch and then uses normal resync serialization. AW EX provides the writer flush; layout/resync coordination excludes conflicting operations, subject to the flush dependency requirement above. Once ordinary resync releases that serialization, writers may resume and stale the non-immediate copy before promotion. That is precisely why promotion must be conditional and why the separate sequence can fail to make progress on a continuously written file.
Everything still open, by area; details are in the sections cited. The shared protocol checklist (<code>iwm_discussion_checklist.md</code>, P1–P8, A7, A9) tracks the verification work.


'''Old client compatibility:''' Clients lacking <code>OBD_CONNECT2_FLR_IMMED_MIRROR</code> are excluded from active IWM epochs, not permanently fenced from writing files with IMMEDIATE mirrors. Any legacy acquisition of a usable layout closes an active epoch first. After that close, a read receives RDONLY with healthy mirrors still clean; a write intent invokes ordinary delayed-write FLR and stales affected secondary components. Existing legacy WRITE_PENDING access outside an IWM epoch follows normal FLR rules. Returning to IWM requires revoking legacy layouts and draining or fencing outstanding legacy writes. See [[#conflicting-operations-during-write-epochs-resync-old-clients|§2.4.6]].
* '''Questions needing a decision'''
** ''Andreas Dilger:'' recovery rollups as a final recovery phase or as ordinary post-recovery operations; recovery hook, cancellation availability and service-resumption timing ([[#recovery-after-mds-failover|§2.2.5]]).
** ''Patrick:'' failure-only close residue: how recovery treats a committed closing layout whose bookkeeping cleanup failed ([[#recovery-after-mds-failover|§2.2.5]], <code>lustre-design-docs-od5.61</code>).
** Whether close reuses SYNC_PENDING internally or an equivalent era-ending step ([[#epoch-close-mds-processing|§2.2.4]]).
* '''Client admission and AW lifetime''' (P1–P3; <code>lustre-design-docs-od5.8</code>, <code>lustre-design-docs-od5.36</code>): admission hook placement and coverage of every path ([[#client-implementation|§2.3]]); IO reference boundaries and the final-flush barrier; voluntary-release gate; mmap fault/PTE handoff and PCC mkwrite; cached PW/AW lifetimes; retention timer value and controls; local lock dependencies in pruning and fan-out.
* '''Compatibility''' (P4; <code>lustre-design-docs-iwm.1</code>, <code>lustre-design-docs-od5.40</code>): capability bit and mirror-state mask; enforcement points and errors; old-read invalidation at entry; primary-only write setting; migration-style promotion integration.
* '''Epoch entry and grants''' (P5–P6; <code>lustre-design-docs-iwm.3.2</code>): reply representation for the two grants; ordered handoff serialization; lost and partial replies, retry and replay.
* '''Release, close and recovery''' (P7–P8; <code>lustre-design-docs-od5.21</code>, <code>lustre-design-docs-od5.29</code>, <code>lustre-design-docs-od5.30</code>, <code>lustre-design-docs-od5.61</code>): durable error-bearing CANCEL delivery; lock progress during recovery; storage schema, identity and shard design; failure-safe execution order; era-ending fence sequencing (<code>lustre-design-docs-iwm.6.1</code>); atomic close and dependency ordering verified on ldiskfs and ZFS; handoff authority after closure.
* '''Secondary failure policies''' (<code>lustre-design-docs-od5.33</code>): timings, controls, sampling; abandonment interface, distribution and reconciliation; dead-OST lifetime and reactivation.
* '''Other operations and durability''' (A7, A9): truncate/punch/fallocate ordering and error contract; DIO flag preservation through fan-out; both-backend tests.
* '''Implementation follow-ups''' with design impact: write-duplication rework of Gerrit 63244 (<code>lustre-design-docs-iwm.4.1</code>); <code>STATE_ONLY_CHANGE</code> with per-mirror prune (<code>lustre-design-docs-iwm.3.3</code>); all-participant component instantiation (<code>lustre-design-docs-od5.49.12</code>); DoM exclusion and admission-path audit (<code>lustre-design-docs-od5.49.14</code>); secondary space accounting (<code>lustre-design-docs-od5.49.8</code>); tiering controls and the redundancy guard (<code>lustre-design-docs-od5.44</code>).
* '''Existing FLR bug found by this design:''' resync can mark mirrors in sync after a stale writer’s late write (<code>lustre-design-docs-od5.49.31</code>; [[#epoch-close-mds-processing|§2.2.4]]).
* '''TBD by design:''' per-stripe error recording and repair ([[#byte-range-error-reporting-in-cancellation-lvb|§3.1]]); restoring lockless DIO ([[#write-ordering-alternatives|§3.2]]); state-conditional access for clients without the capability ([[#conflicting-operations-during-write-epochs-resync-old-clients|§2.4.6]]).


'''Remaining implementation work:'''
<span id="decision-log"></span>
= Decision Log =


* Expose conditional IMMEDIATE promotion and demotion through the layout-management interface and tiering controller, including retry handling and the retained-copy redundancy policy. The ordering and safety conditions above are requirements; the concrete serialization mechanism remains to be specified.
Decisions recorded with their dates. The sections above state the current rules; this log records when each was settled and what it replaced. Beads are in the <code>lustre-design-docs</code> tracker.


<span id="heterogeneous-compression-across-mirrors-resolved"></span>
{| class="wikitable"
== Heterogeneous Compression Across Mirrors (Resolved) ==
|-
! Date
! Decision
! Where
! Replaces
|-
| 2026-09-08
| Application completion contracts A1–A6 (buffered return, dirty-data ownership, RPC completion, DIO durability, explicit sync with degraded success, no global-close barrier)
| §4.1
| —
|-
| 2026-10-02
| Interrupted epoch opening uses ordinary recovery: after replay and reconciliation, listed epochs with no recovered holders close through the ordinary close
| §2.2.5
| A separate interrupted-opening outcome or admission acknowledgement
|-
| 2026-10-02
| The first AW request opens the epoch (primary selection, WRITE_PENDING, INFLIGHT, durable active-epoch record); later requests join
| §1.2, §2.4.1
| Epoch entry driven by a later write intent
|-
| 2026-10-02
| Strict LAYOUT → ACTIVE_WRITERS ordering on client and server; soft client layout hold; component/SEL changes are layout-only
| §1.2, §2.2.7
| AW-first ordinary close
|-
| 2026-10-02
| Capability-based compatibility: patched 2.18/master and 2.15 clients skip unknown mirror-state flags and advertise it; others cannot open IMMEDIATE files in any epoch state
| §1.2, §2.4.6
| Blanket epoch closure on every non-IWM layout request; state-conditional access (deferred)
|-
| 2026-10-02
| Capability-bearing non-IWM clients: writes denied by default; optional setting allows primary-only writes that stale every non-primary mirror via ordinary FLR intents
| §1.2, §2.4.6
| —
|-
| 2026-10-02
| Epoch close uses LAYOUT EX → AW EX; AW cancellation drains old IO and flushes the whole file without taking new locks
| §2.2.4, §2.2.6
| AW-first close; layout reacquisition during drain
|-
| 2026-10-02
| Entry reply returns the updated layout and a replacement LAYOUT grant with the independent AW grant; reuse FLR’s layout handoff and client refresh
| §2.2.7
| Two round trips; LDLM lock conversion
|-
| 2026-10-05
| Durable participation recorded for every client’s AW acquisition, including joins
| §2.2.5
| FID-only active tracking with filesystem-wide degradation on client loss
|-
| 2026-10-05
| Mirror-error records are distinct from participation, carry no reporter identity, and are consumed only by the atomic close
| §2.2.3, §2.2.5
| Errors annotated on, or living with, participation
|-
| 2026-10-05
| Recovery order: load tracking, finish replay and reconciliation, then roll up; primary error resolved before the lost-participant fallback
| §2.2.5
| —
|-
| 2026-10-05
| Last voluntary release triggers rollup; any error-bearing release triggers it immediately; raced joins are drained; no “rollup pending” gate
| §2.2.4
| —
|-
| 2026-10-05
| Error-bearing cancellation: one MDT transaction records errors and retires participation, durable before ACK
| §2.2.3
| —
|-
| 2026-10-05
| Close: one MDT transaction publishes the closing layout, consumes handled errors and removes the active-epoch record; no extra synchronous close commit
| §2.2.4
| Layout publication followed by separate cleanup
|-
| 2026-10-05
| Canonical client admission: LAYOUT, AW, revalidate; on layout loss drop the IO’s AW reference before waiting
| §2.2.7
| —
|-
| 2026-10-06
| Three secondary failure policies (wait indefinitely, slow-fail, fast-fail) differing only in timing; administrative abandonment is separate
| §1.2, §2.3.3.1
| Two-mode fast/slow model
|-
| 2026-10-06
| Reuse <code>unmap_mapping_range()</code> for voluntary/idle AW mapping revocation
| §2.2.2, §2.2.7.3
| A new mapping-revocation scheme
|-
| 2026-10-06
| Secondary policy per client mount, default finite slow-fail; changes affect new IO only
| §2.3.3.1
| Per-file policy
|-
| 2026-10-06
| Administrative abandonment is an OST-targeted client action, local or triggered cluster-wide when an OST is declared dead
| §2.3.3.1
| Per-file mirror abandonment
|-
| 2026-10-06
| Promoting IMMEDIATE on an open file is a migration-style protected operation; old descriptors are not grandfathered
| §2.4.6, §4.2
| Refuse promotion until incapable holders close
|-
| 2026-10-07
| No eligible replacement: keep the existing primary as the sole best-effort copy
| §2.4.4, §2.2.5
| All mirrors STALE
|-
| 2026-10-07
| Unmatched replayed AW is rejected and handled conservatively, not an LBUG (review F-13)
| §2.2.5
| LBUG
|-
| 2026-10-07
| Logically separate record kinds; in-memory client lookup rebuilt on restart; recovery rollups by bounded parallel workers with no progress journal
| §2.2.5
| —
|-
| 2026-10-07
| Storage mechanics deferred to detailed design; dedicated DT indexes are the preferred candidate
| §2.2.5
| —
|-
| 2026-10-07
| <code>lfs mirror create --immediate</code> marks all created mirrors IMMEDIATE
| §1.2
| —
|-
| 2026-10-07
| V1 scope: DoM excluded; NFS, PCC and HSM supported under normal admission and drain
| §1.2
| —
|-
| 2026-10-07
| Hybrid IO takes the DIO client lock; group locks allowed with their existing no-ordering contract (review F-07)
| §1.2
| —
|-
| 2026-10-07
| Synchronous durability at every AW release; no opt-out
| §2.2.2
| <code>iwm_sync_on_epoch_close</code> opt-out
|-
| 2026-10-07
| AW cache retention: ordinary LDLM semantics plus a separate shorter timer (about 60 s)
| §2.2.2
| Dedicated 1–5 s idle timer
|-
| 2026-10-07
| Drain then FLR era-ending version fence for lost participants (no MDT extent-lock flush)
| §2.2.4
| OST extent-lock flush as the fence
|-
| 2026-10-07
| Fence only lost-participant closes; close does not wait for delivery; copy-to-clean operations confirm the fence first (review F-03)
| §2.2.4
| —
|-
| 2026-10-07
| Epoch ID is the opening layout generation (review F-04)
| §2.2.1
| —
|-
| 2026-10-07
| Cancellation report carries failed mirror IDs, not a bitmap (review F-05)
| §2.2.3, §3.1
| Counted mirror bitmap
|-
| 2026-10-07
| Clean cancellations commit lazily by default; MDT commit cost model (review F-15)
| §2.2.3
| Optimization to evaluate
|-
| 2026-10-07
| No maximum epoch length (review F-16)
| §2.2.2
| —
|-
| 2026-10-07
| Release barrier built on per-file transno tracking against <code>last_committed</code>; DIO already durable at reply (review F-25)
| §2.2.2
| —
|-
| 2026-10-07
| Component instantiation covers every participant; a secondary instantiation failure is a secondary error
| §2.2.7.2
| Primary-only instantiation
|-
| 2026-10-07
| <code>STATE_ONLY_CHANGE</code> prunes sub-objects of mirrors that lose read eligibility (review F-01)
| §2.2.7.3
| Keeping all cached pages
|-
| 2026-10-07
| Secondary ENOSPC/EDQUOT handled when attempted; no advance reservation in v1
| §2.3.3.1
| —
|-
| 2026-10-07
| FIEMAP reports the primary only
| §2.3.2.1
| —
|-
| 2026-10-07
| Primary RPC slot freed at its reply; only completion waits for secondaries (review F-20)
| §2.3.1.1
| Prototype’s slot hold
|-
| 2026-10-07
| Ordering invariant: no primary extent lock release while a covering secondary attempt is unresolved (review F-06)
| §2.3.1.1
| —
|-
| 2026-10-07
| Heterogeneous compression via per-mirror rebuild with chunk alignment to the largest participant chunk; bounce pages reused when settings match (review F-09)
| §2.3.1.5
| “OST handles RMW”; bounce reuse deferred
|-
| 2026-10-07
| Gerrit 63244 cited as a feasibility demonstration with a deltas table (review F-21)
| §2.3.1
| “Proof of concept”
|-
| 2026-10-07
| Clients without the capability may still stat IWM files; transient size inaccuracy accepted (review F-11)
| §2.4.6
| —
|-
| 2026-10-07
| Administrative redundancy guard: demotion or removal leaving fewer than two healthy IMMEDIATE copies needs force
| §4.2
| Deferred to end of review
|}


Resolved — heterogeneous compression across immediate mirrors does not require special handling. Each mirror’s OSC compresses independently from the original source pages; the redundant compression cost is negligible for 2-3 mirrors. See [[#compression-and-encryption-across-mirrors|Bounce Page Reuse]] for the full analysis.
[[Category:Architecture]]
[[Category:FLR]]
[[Category:Layout]]

Latest revision as of 13:42, 7 October 2026

Migration draft. Copied from the committed local design in paf0186/lustre_design_docs (projects/immediate-write-mirroring/design/immediate_write_mirroring_design.md; source commit feb9fd325f72f2b2f1df12e7355586514822a739), with diagrams committed in 21b52e0. The original Confluence page is 373565003 on wiki.whamcloud.com. Historical discussions and server revisions are archived separately; they are not recreated as native wiki history here. Source-code references are given as plain-text paths and line numbers at Lustre commit 47638add78158a74441ed3e56392f5a720cef764 (Linux references at v6.12) rather than links.

Overview & Design Summary

FLR Today

In the current implementation of FLR (introduced in Lustre 2.11), the system uses a "delayed write" approach for maintaining file mirrors. This means:

Writing to Mirrored Files: When a client writes to a mirrored file, only one primary (preferred) mirror is updated directly during the write operation. The other mirrors are simply marked as "stale" to indicate they're out of sync with the primary mirror.

Today, we have manual synchronization: after a write, the lfs mirror resync command must be run to synchronize the stale mirrors with the primary mirror. This command copies data from the synced mirror to the stale mirrors and removes the stale flag from successfully copied mirrors.

Layout state and staleness are managed through a careful series of layout state changes; see the file-level replication state machine in Core Design Concept in this document.

This delayed write approach was implemented in the first phase of FLR to avoid the complexity of maintaining consistency across multiple mirrors during concurrent writes. By updating only one mirror during writes and marking others as stale, the system maintains a consistent view of the file data, at the cost of requiring explicit synchronization after writes complete.

The current implementation does not provide immediate redundancy, since only a single mirror is up to date until an explicit resync operation is performed. This approach enables things like hot pools synchronization, but the lack of immediate write redundancy severely limits the use cases.

Immediate Mirroring

We have a requirement to do immediate mirroring on Lustre files, where all writes (and related ops) are replicated to multiple mirrors immediately. The infrastructure created for this will also be used for immediate erasure coding.

Whether a mirror takes part in immediate write mirroring is controlled by a per-mirror IMMEDIATE flag in the composite layout (lcme_flags), alongside existing flags like STALE and PREFER. Mirrors without the flag are ordinary FLR mirrors, written lazily via resync. Immediate and non-immediate mirrors can coexist on the same file.

The participants of a write epoch are the IMMEDIATE mirrors that are not STALE when the epoch opens, primary included. Writes and other modifying operations go to every participant. Non-immediate mirrors are not written; when a modifying operation omits a clean non-immediate mirror, its affected components are marked STALE under ordinary FLR rules. Changing which mirrors are IMMEDIATE requires an epoch boundary.

lfs mirror create --immediate marks every mirror the command creates IMMEDIATE, on all of its components; explicit per-mirror component flags allow mixed immediate/non-immediate layouts. Existing files change membership only through the conditional promotion and demotion operations in §4.2. The shorthand implies no minimum number of copies (implementation: lustre-design-docs-iwm.2.3).

The goal is to have redundancy immediately, but during writes only a single mirror (the primary) is available for reads — see Primary Mirror Selection and Read Visibility for the rationale.

Compatibility is capability-based. Clients we can modify (2.18/master and 2.15) treat unknown mirror-state flags as STALE for mirror selection and advertise this with a separate connect capability, so they skip INFLIGHT mirrors even without full IWM support. The MDT refuses to open files with IMMEDIATE mirrors for clients without that capability, whether or not an epoch is active. Capability-bearing clients without full IWM support can read the primary; their writes are denied by default, or, with an optional setting, use ordinary FLR write intents that mark every non-primary mirror STALE and write the primary only. See Old-Client Compatibility.

V1 scope. Data-on-MDT is excluded: layouts combining DoM with IMMEDIATE mirrors are rejected, including on promotion; ordinary DoM is unchanged. NFS re-export, PCC and HSM are supported under the normal layout/AW admission and drain rules, and every IO and lifecycle path they use must be covered (lustre-design-docs-od5.49.14; mmap in lustre-design-docs-od5.36).

The core idea of the design is this:

To write an IWM file, the client first obtains a valid layout, then takes or references a cached Active Writer (AW) lock at the top of the modifying IO, before it can dirty data or obtain or use PW write authority. AW is a CW lock on a separate IBITS bit (ACTIVE_WRITERS) on the same per-file resource. The first AW request opens an epoch: the MDT selects the primary by the usual policy, moves the file to WRITE_PENDING, sets INFLIGHT on the other participants, and makes the active-epoch record durable before write authority becomes usable. Later AW requests join the open epoch. The layout hold is soft and can be revoked without waiting for a flush. The server always takes LAYOUT before ACTIVE_WRITERS, with EX on both, to close an epoch or for any operation needing both; ordinary layout changes within an epoch (component instantiation, SEL extension) revoke LAYOUT alone and leave AW untouched. See AW Lock vs Layout Lock Interaction.

The client sends all writes to all participating mirrors in parallel. A single primary mirror will be selected for reads during this time, since we cannot guarantee all mirrors are identical during writes. This mirror is the "write leader", also used for write ordering (extent locks are taken on this mirror only). All other mirrors are marked INFLIGHT during writes — see Mirror States for the full flag model.

Clients hold AW until their admitted modifying IO has drained, the resulting dirty data has been flushed, and each participant’s updates are durable on OST storage or recorded as failed, with outstanding IO safely resolved. Per-mirror errors are reported to the MDS in the AW cancellation. On error, or for administrative operations, the MDS takes layout EX then AW EX, which forces writers through this release path; it then clears INFLIGHT from clean mirrors and sets STALE on failed ones. Secondary IO has three failure policies (wait indefinitely, finite slow-fail, finite fast-fail) that differ only in timing; administrative abandonment of a dead OST is a separate operation. See Secondary Failure Policy, Write Operation Flow and Active Writer Lock.

Write ordering is enforced by requiring client-side LDLM extent locks on the primary mirror. This means DIO to IWM files must use client-side locks where today it operates locklessly — a performance regression for shared-file DIO workloads. Restoring lockless DIO is a high priority for future work; see Write Ordering Alternatives. Hybrid IO, which turns large buffered IO into DIO, is DIO for this purpose and takes the same client lock. Group locks are allowed unchanged: group-lock holders already get no write ordering between them, and with IWM that includes possible differences between mirrors for overlapping writes, which are not detected or marked STALE.

Client eviction, MDS failover, write leader failure, and other recovery scenarios are covered in Failure Handling.

Relationship to Erasure Coding

Immediate mirroring is separate from FLR Erasure Coding (Confluence page 105093375) (read-only EC), and can be done partly in parallel. IWM will be the foundation of immediate erasure coding — the write duplication infrastructure, Active Writer lock, epoch management, page consistency mechanisms, and error reporting all carry over to EC. Immediate EC will be covered in a separate design document.

Detailed Design

The write flow, AW lock mechanics, client-side IO duplication, and failure handling are each covered in the subsections below. Layout handling proceeds similarly to existing FLR, except that secondary mirrors are marked INFLIGHT rather than STALE during writes — see Mirror States for the full flag model.

FLR Mirroring

FLR Immediate Mirroring State Machine

FLR Immediate Replication

Write Operation Flow

At the top of each write or other OST-modifying IO (see §2.3.2), the client obtains a valid layout first and then takes an AW reference, acquiring the lock or reusing a cached one (see Lock Ordering). This precedes primary PW acquisition/reuse and any dirtying; AW is not first acquired at writeback or BRW submission. Ordinary buffered write() may return after accepting data into the client cache, with normal aggregation and later BRW fan-out to the participating mirrors. The primary mirror remains readable; secondaries are INFLIGHT (see Mirror States).

The client holds AW across this write-permission window and its resulting dirty data and IO. Before releasing AW, it must stop further dirtying under that authority, obtain committed or recorded-failed outcomes for every participating mirror, and safely resolve outstanding IO (see Lock Lifetime). It releases AW with the mirror-error summary (see Error Reporting). Application return, RPC completion, durable commit, AW release, and global epoch closure are distinct events; the agreed application contracts and remaining mechanism questions are recorded in Operation Completion Consistency Model.

In the normal (no-error) case, once all clients release their AW locks, the MDS closes the epoch and transitions the layout back to RDONLY. If any client reports errors, the MDS forces epoch closure — see Epoch Close for details.

Example Scenario

With one client, if there are three mirrors and three writes:

If write 1 errored to mirror 0
If write 2 errored to mirror 1

The client will report errors on mirror 0 and mirror 1 to the MDS as part of AW lock cancellation (see Active Writer Lock for the LVB structure). The MDS would then clear INFLIGHT from mirror 2 (it is now clean) and mark mirrors 0 and 1 as STALE (clearing INFLIGHT, adding STALE). Userspace can try to resync to these mirrors, and if this fails, will need to add new mirrors.

If all mirrors fail writes during an IO and no successful eligible replacement exists, report the failures through the usual IO error and logging paths. On epoch close, retain the existing primary as the sole valid, read-eligible best-effort replica, clear INFLIGHT and mark every other mirror STALE. The primary may be incomplete; selecting it does not turn failed IO into success. Resync can use its current contents to restore redundancy, but cannot reconstruct missing updates. See the selected policy and review note in §2.4.4.

Primary Mirror Selection and Read Visibility

The primary is the only readable participant during an epoch: without MVCC (which Lustre’s storage backends lack), writes reach different mirrors at different times, and concurrent readers could otherwise see inconsistent data. At epoch entry the MDT marks every other participant INFLIGHT. IWM clients, and patched non-IWM clients through their unknown-flags-as-STALE rule, exclude INFLIGHT mirrors from reads and from OST-derived metadata such as file size. The primary is never INFLIGHT. Clients without the safe-selection capability cannot open IMMEDIATE files at all (§2.4.6). Reads already in flight at entry rely on the layout invalidation and revalidation that entry triggers. See Mirror States. However, this is not possible for DIO, which does not use LDLM locks on the client, but instead only uses them locally on each separate server. (Superseded: v1 DIO to IWM files takes client-side extent locks on the primary; see §1.2.)

Active Writer Lock

This section covers the Active Writer (AW) lock: how it is used, what it guarantees, and how the MDS uses it to manage write epochs. Throughout, “flush before release” includes failure resolution: successful mirrors must be durable, and terminally failed mirrors must be reported with their outstanding IO safely resolved.

Lock Semantics

The AW lock is a CW (concurrent write) lock on a dedicated MDS IBITS bit, ACTIVE_WRITERS. Many clients can hold it at once, and the MDS uses its presence to know whether a file has active writers. When the MDS needs exclusive access to both layout and write epoch (epoch close, mirror replacement, resync), it takes layout EX first and then AW EX, which forces every holder to drain admitted IO, flush and release.

Every distributed AW acquisition, whether it opens the epoch or joins an open one, durably records that client’s participation before its write authority becomes usable (§2.2.5; cost in §2.2.3). Taking an IO reference to an already-cached AW grant acquires nothing new and writes nothing.

Epoch identity. An epoch is identified by the file plus the layout generation of the transition that opened it (RDONLY → WRITE_PENDING). Entry already bumps the generation, so the value is unique and increasing per file; the current generation cannot serve because it changes within an epoch. The MDT stores it in the active-epoch record and returns it with the AW grant; the client puts it in the cancellation report header; participation and error records carry it. Since only one epoch per file can be open at a time, the file alone identifies the open epoch on the MDT; the epoch ID matters for messages that outlive their epoch. The MDT discards, and logs, any cancellation report or replayed AW lock whose epoch ID is not the file’s open epoch. For example, an error report retried after an MDT restart, whose lock was cancelled locally and so not replayed, was already covered by the lost-participant rule when that epoch closed and must not be applied to a later one.

Lock Lifetime and Commit Requirements

Admission and coverage. Every modifying IO obtains a valid layout, takes an AW reference, then checks that its soft LAYOUT grant is still valid before it acquires or reuses primary PW write authority or dirties data; if the layout was lost it drops the AW reference and retries from the layout (§2.2.7). So AW cancellation must wait even for an admitted IO that has not dirtied anything yet. Dirty data belongs to the epoch as soon as it is dirtied, not when a BRW is submitted, and keeps the file’s AW obligation alive after the IO ends; a layout refresh within the epoch does not move that obligation to a later epoch. Cached PW reuse and mmap write permission obey the same rule.

Release condition. The client holds AW until nothing more can be dirtied under it and every participant has a resolved outcome: its updates confirmed durable on OST storage, or its failure recorded for the MDS. Recording a failure alone does not permit release: outstanding sends, retries and replay must first complete or be safely cancelled or fenced. A mirror reported successful must be durable; an RPC reply alone is not enough.

This is stronger than for ordinary Lustre writes. Without IWM, a buffered write that reached OST memory but not the journal is lost if the OST crashes, which is data loss. With IWM, if one mirror’s OST commits and another’s does not, the mirrors silently diverge with no signal to the MDS, and that inconsistency can corrupt reads after recovery.

The release barrier. Every AW release, whether forced, voluntary or idle, and even while other clients keep the epoch open, synchronously establishes these outcomes; there is no tunable to disable it, and no secondary timing policy weakens it. Each OST import tracks last_committed. At release:

  1. For each OST object written during the epoch on a mirror not already recorded as failed, check whether the highest transno among its successful write replies is ≤ that import’s last_committed.
  2. If all remaining updates are committed and failed-mirror IO is safely resolved, release with the accumulated error report.
  3. For objects still behind, issue an object-scoped OST_SYNC (osc_sync_base()) and confirm durability. Secondary sync and recovery waits follow the mount’s secondary failure policy. A terminal inability to establish durability is recorded as a mirror failure and its IO safely resolved; release does not require a failed mirror to commit.

This barrier does not make buffered write() synchronous, change the DIO or explicit-sync contracts, or require a synchronous commit of the MDT’s closing transaction.

Existing mechanisms. At 47638add78, every write request stays on the import replay list until the OST commits it, and its rq_commit_cb (brw_commit, osc_request.c:2759-2771, 2922) fires then; unstable-page accounting and soft sync (OBD_BRW_SOFT_SYNC) build on it. That gives commit notification per RPC but no per-file view, so IWM adds per-file tracking: for each OST object the file wrote during the epoch, the highest transno from successful write replies, recorded at reply time. PW lock cancellation (osc_lock_flush) waits for replies, not commits, so it cannot serve as the barrier. The OST sync_lock_cancel setting is not relied on: in effect it defaults to never (ofd_slc_set() sets always when sync_journal=0, but tgt_init() later resets it; ofd_dev.c:3157, tgt_main.c:493), and where enabled it covers only the primary’s OST.

DIO. Successful IWM DIO completion means stable storage, with or without synchronous flags: each participant’s updates are committed or recorded as failed, outstanding activity is resolved, and the primary determines the result. At 47638add78, osc_page_submit() sets OBD_BRW_SYNC on every immediate transfer, including DIO (osc_page.c:302), and OFD sets th_sync whenever a niobuf lacks OBD_BRW_ASYNC (ofd_io.c:1309-1319), so a successful DIO reply is already journal-committed. Secondary DIO RPCs rebuilt by fan-out must carry the same flags. Buffered IO tracking continues after write() returns, through writeback, RPC completion and commit.

Failures. A client that dies without releasing AW is handled by eviction and recovery. For a live client, failures during submission, transport or recovery, the modifying RPC, or commit/sync are reported once terminal under the selected policy; a retryable event that recovers is not a mirror error. Terminal failures lead to STALE through the cancellation report and epoch close.

Caching and retention. The client caches its AW grant so that most writes run in an already-open epoch: each modifying IO validates its layout and takes a reference to the cached grant, with no MDT round trip. An AW enqueue that joins an unchanged epoch reuses it and a still-valid layout grant, without repeating primary selection, WRITE_PENDING/INFLIGHT publication, active-epoch insertion or layout replacement; it does record durable participation. Caching never bypasses layout validation or cancellation gates. Retention uses ordinary LDLM lock-use and recency semantics, with one difference: AW has its own shorter retention timer, proposed at about 60 seconds, so healthy secondaries do not stay INFLIGHT long after writing stops.

Timer expiry starts the normal release; it does not itself release AW or clear INFLIGHT. The client first gates or retires covered PW write authority and writable mappings (reusing unmap_mapping_range() with the AW admission interlock, §2.2.7.3), then drains admitted IO, flushes, and passes the release barrier. A cached PW lock may not allow new dirtying after AW release. The last holder’s release triggers the ordinary rollup that restores secondaries’ read eligibility.

There is no maximum epoch length. Because lock use resets the retention timer, a continuously written file keeps its epoch open and serves reads only from its primary until writing pauses; nothing is at risk, since every AW release is durable on all participants. A forced periodic rollup would drain and sync every writer only for the next write to reopen the epoch. Operations that need a closed epoch (resync, membership changes) force closure through LAYOUT EX → AW EX.

Open items. Exact AW reference points, timer/admission races, mmap fault and PTE handoff, and cached PW/AW lifetime integration (lustre-design-docs-od5.36, lustre-design-docs-od5.8 P1–P3); the retention timer value and controls; confirming that fan-out preserves the DIO sync flags, and testing on both backends (A9).

Error Reporting via Cancellation LVB

When the client releases AW, it reports write results to the MDS in the client-to-MDS cancellation request. This is new protocol: the existing LDLM_CANCEL format carries no LVB. The report is a versioned, length-delimited, extensible message (§3.1). In v1 it carries the epoch ID and a counted list of failed mirror IDs, with no per-stripe detail. Errors are rare, so the list is usually empty; naming mirrors directly needs no numbering shared between client and MDS. The MDS validates each ID against the epoch’s participants (a report naming a mirror that was not written is invalid and gets conservative handling), takes the union of all valid reports for the epoch, and marks each named mirror STALE as a whole. CLIO collects errors from every modifying operation covered by AW, including setattr. Per-stripe error recording is TBD.

Durable acceptance. An error-bearing cancellation is acknowledged only after one MDT transaction has made durable both the reported errors and the removal of that client’s participation. Error records name the file, epoch and mirrors, with no durable association to the reporting client (debug logs may record it); in-memory receipt is not enough. Error records are distinct from participation: the reporter can release AW and retire its participation without waiting for rollup (holding AW until then would block the AW EX that resolves the error), and the error record survives until the closing transaction consumes it. Recovery therefore sees either the old participation, to be reconciled, or retired participation with its errors safely recorded, never participation removed with its errors lost. This transaction does not remove the active-epoch record.

Clean cancellations commit lazily. For zero-error cancellations, participation removal becomes durable with the next regular MDT commit; the MDT neither forces nor waits for a commit before acknowledging. The cost is a short window in which an MDT crash leaves the old participation record, and if that client also fails to reconnect, the file is degraded unnecessarily.

MDT commit cost. Where IWM forces an MDT commit wait, relative to delayed-write FLR:

Event Forced commit wait? Cost relative to FLR
Epoch entry (RDONLY → WRITE_PENDING, active-epoch record) yes none extra: FLR’s write-intent transition is already synchronous (th_sync=1, mdd_object.c at 47638add78), and the record shares that transaction
Another client joining an open epoch yes new: one per client per file per epoch; the AW grant is then cached
Error-bearing AW cancellation yes new, rare
Clean AW cancellation no (lazy) an ordinary transaction, typically when cached AW expires, off the application IO path
Epoch close / rollup no an ordinary transaction

Concurrent forced commits share journal commits (jbd2 batches them; ZFS waits on a txg), so the cost shows mainly as MDT commit pressure rather than application latency.

Terminal errors release promptly. When an error becomes terminal under the selected policy, or administrative abandonment declares a secondary failed, the client resolves the remaining epoch IO and releases AW without waiting for the retention timer, reporting the failed mirrors. Any voluntary release carrying an error triggers rollup immediately, even with other holders (§2.2.4). A retryable event that recovered does not.

Open items.

  • Durable error-bearing CANCEL delivery (lustre-design-docs-od5.29). Stock LDLM cancellation does not deliver the report durably: at 47638add78, ldlm_cli_cancel_local() (lustre/ldlm/ldlm_request.c:1382) cancels the local lock before the CANCEL RPC, and ldlm_cli_cancel_req() (lustre/ldlm/ldlm_request.c:1598) treats LUSTRE_ESTALE as success and tolerates other failures. The client must keep the pending report until durable acceptance, and the server must record it before lock destruction and reprocessing can lose its association. A stale or unknown cookie is not proof of acceptance; the epoch ID prevents applying it to a later epoch. Specify retries, lost acknowledgements, persistence failures, and reconnect when the cancelled lock is no longer replayed.
  • Packing, limits and per-lock association in batched and early-cancel RPCs (§3.1).

Epoch Close: MDS Processing

The last voluntary AW release triggers rollup, and so does any voluntary release carrying an error report, immediately, whatever other holders remain. A trigger starts closure; it does not mean the epoch is closed. The server always takes LAYOUT EX, then AW EX, even when there were no holders at the trigger. A clean last release and an error-triggered release enter the same sequence:

  1. The client drains its work. It finishes admitted modifying IO, flushes the file’s dirty data and resolves mirror IO and durability under its existing protection, then reports failed mirrors in the AW cancellation. It never releases AW with dirty data or unresolved admitted IO, and acquires no new distributed locks to finish old work.
  2. The MDT accepts responsibility durably (§2.2.3). The reporter is then done. The epoch, its active-epoch record and all accumulated errors remain.
  3. Rollup starts. The MDT requests LAYOUT EX, then AW EX. Until LAYOUT EX is granted, another client may still acquire AW and join the epoch; AW grants are not parked behind a “rollup pending” gate, which would create a circular wait.
  4. Remaining writers drain. LAYOUT cancellation invalidates their layout views. A restarting IO drops its AW reference before waiting for a fresh layout, while old dirty data keeps file-level AW protection. AW EX cancels and drains every remaining holder, including late joiners, each finishing with a file-wide flush and no layout reacquisition. Their error reports get the same durable acceptance.
  5. The MDT establishes the outcome, with both EX locks held, from all reports and durable error records, using the outcome rules below.
  6. One transaction closes the epoch. It publishes the RDONLY layout with the resulting INFLIGHT/STALE flags, consumes the error records it handled, and removes the active-epoch record.

Outcome rules, applied in this order (also used by recovery, §2.2.5):

  • A known primary error first. Replace the failed primary with an eligible mirror that has no known failure, and mark the old primary STALE. If none is eligible, report the failures and keep the existing primary as the sole best-effort copy (§2.4.4); this does not mean the failed writes succeeded.
  • Then any lost participant. If a participant was evicted or lost, its outcome is unknown: every mirror other than the resulting primary becomes STALE in full, however few mirrors any reports named, and the primary is kept as a best-effort copy.
  • Otherwise, ordinary results. Mirrors with reported errors become STALE; the others lose INFLIGHT and are clean. Pre-existing STALE is never cleared by a clean epoch; only resync clears it.

STALE mirrors are visible to lamigo through changelogs, the same mechanism Hot Pools uses, and it attempts resync (§2.4.5).

Atomic close, no extra commit wait. Recovery sees either the pre-close state (active-epoch record and error records) or the complete closing layout with that bookkeeping gone. If the close transaction is lost before it commits, the old evidence remains and recovery repeats rollup; if it commits, the layout carries the outcome. This holds even after every client has released AW, because the active-epoch and error records are not tied to participation. So close needs no dedicated synchronous commit; entry and error-bearing cancellation still do. Later epoch entry, resync and other layout operations must not become durable while losing an earlier close they depend on, nor reinterpret its errors as a later epoch’s. Within one MDT’s backend this ordering holds naturally: ldiskfs synchronous transactions wait for a journal commit and ZFS waits for the transaction group, so a later durable entry or participation record also makes the preceding close durable (osd_trans_stop() in both OSDs at 47638add78). It is not a commit across MDTs or OSTs.

On a hot file closure is expensive: every holder must finish the synchronous release before AW EX is granted. Finite fast- and slow-fail timings bound secondary recovery waits; wait-indefinitely can keep closure waiting until recovery or administrative abandonment. Administrative operations such as mirror replacement and resync use the same sequence.

Lost participants: drain, then version fence. An evicted client cannot be counted as drained: it may still have old-version writes in flight and access to the OSTs. Its outcome is covered by the lost-participant rule above; late writes are rejected by the existing FLR write-era mechanism, not by an MDT-driven extent-lock flush (the MDT has no way to take OST extent locks). FLR’s era boundary is specific: a push carrying LU_LAYOUT_RESYNC raises the OST object’s minimum accepted version, while an ordinary generation update only widens the accepted range (era transition (lustre/lod/lod_object.c:8742), OST range handling (lustre/ofd/ofd_objects.c:608), old-version rejection (lustre/ofd/ofd_io.c:443)). The push travels from the MDT through OSP asynchronously (delivery conditions (lustre/osp/osp_sync.c:244)).

  • Only closes with a lost participant are fenced. If every participant drained through AW cancellation, nobody retains write authority and no fence is needed. A lost-participant close, live or in recovery, pushes the era-ending version to every object of every participant mirror, including the primary; the next epoch’s entry then opens a new era above the lost client’s version.
  • Close does not wait for delivery. After such a close every non-primary mirror is STALE and the primary is best effort. A late write to a STALE mirror is harmless; a late write to the primary is the application’s own data arriving late, with no other readable copy to diverge from. Publishing the layout, restoring readability and admitting the next epoch do not wait for the push.
  • Copy-to-clean operations wait. Divergence becomes possible only when copies are made to agree again: resync, mirror extend, or any copy that clears STALE. Before reading the source mirror, such an operation must confirm the era-ending version is applied on the source objects; a queued OSP record is not confirmation. osp_sync() (osp_dev.c:860) waits for an OST’s pending records, with a timeout and -ENOTCONN when disconnected; a narrower per-object wait may be preferable. Target objects fence themselves, because resync writes carry LU_LAYOUT_RESYNC.
  • Existing FLR resync has the same window (lustre-design-docs-od5.49.31). Resync start pushes gen|LU_LAYOUT_RESYNC to the primary asynchronously and copies immediately, with no group lock, lockless DIO reads and no data-version check at resync-done, so a writer evicted from the MDT but still connected to the OSTs can write the primary after resync copied that range. The gate above fixes FLR as well.

Open items. Era-ending sequencing (lustre-design-docs-iwm.6.1): MDT transaction and OSP delivery ordering, writes already prepared before the version check changes (bulk IO, commit, retries, replay), disconnected OSTs, and integration with the atomic close and crash recovery; whether close reuses SYNC_PENDING internally or an equivalent era-ending step. Atomic crash recovery and dependency ordering must be verified on ldiskfs and ZFS.

Recovery After MDS Failover

AW lock state, including which clients hold AW, lives in MDT memory and is lost on restart. To close interrupted epochs safely, the MDT must know which files had open epochs, which clients took part, and which mirror errors were already reported. It keeps that in durable tracking.

The primary of an open epoch is recoverable from the layout itself: INFLIGHT is stored durably in lcme_flags, and the primary is the IMMEDIATE mirror without INFLIGHT. Non-immediate mirrors are never primary candidates merely because they lack INFLIGHT.

Durable tracking. The MDT keeps three logically distinct kinds of record:

  • Active-epoch record (file plus epoch ID). Inserted by the opening AW request, in the same transaction that moves the layout to WRITE_PENDING and sets INFLIGHT. Removed only by the atomic closing transaction (§2.2.4).
  • Client participation (client, file, epoch). Recorded durably as part of every client’s AW acquisition, including joins of an open epoch, before that client’s write authority is usable. Retired by that client’s cancellation transaction (§2.2.3).
  • Mirror-error record (file, epoch, failed mirror IDs). Recorded durably before an error-bearing cancellation is acknowledged; it carries no durable reporting-client association. Consumed only by the atomic closing transaction, so it survives the reporter’s AW release and participation retirement.

Each kind is independently updateable; there is no growing per-file client list rewritten on every join or release. The MDT also keeps an in-memory client → file/epoch lookup, maintained with live participation and rebuilt from the durable records on restart; it is not stored separately. Participation is recorded per client, rather than per file only, so that losing a client degrades only the files it was writing. The cost is one forced MDT commit per client join (see the cost model in §2.2.3); cached AW references add no further writes.

Two alternatives were rejected: scanning all inodes for INFLIGHT mirrors (correct, but prohibitive on large filesystems) and relying on clients’ AW lock replay alone (fails exactly when a client is evicted, the case recovery exists for).

Recovery sequence.

  1. A close that became durable needs nothing. Its layout outcome is on disk and its tracking is gone. A close that did not become durable left its active-epoch and error records, so the epoch is simply still open.
  2. Load the tracking. Read active-epoch, participation and error records; mark epochs with error records as needing rollup. A recorded error does not need its reporter to reconnect.
  3. Finish ordinary lock replay and reconciliation. Rebuild surviving AW holders and match them to participation records. Do not cancel holders while replay is still assembling them, and do not make a missing-holder decision from partial state. A participant that fails to recover is lost and is evicted. An admitted client with no participation record is never assumed not to have written. A replayed AW lock with no matching active-epoch record (same file and epoch ID) is rejected; see below.
  4. Then roll up affected epochs with the ordinary close. Epochs with error records, and epochs left with no recovered holders, close through the same LAYOUT EX → AW EX sequence as a live close (§2.2.4), without waiting for recovered holders to go idle. A writer that joins before LAYOUT EX is granted is drained by that same rollup. Independent files are processed by bounded parallel workers; the active records are the authority, so unfinished work is rediscovered after another restart without a progress journal.

Each recovery rollup applies the ordinary outcome rules (§2.2.4): a known primary error first, then the lost-participant rule (which affects only files whose participants did not all recover or release safely), then ordinary per-mirror results; late writes from a lost client are fenced as described there.

Consumed error records and the active-epoch record are removed in the closing transaction itself; they are never deleted early, and never kept just to replay a released reporter’s lock. An active-epoch record for a file that is no longer an IWM file is removed only after its error records are handled.

Replayed AW without a matching active-epoch record. This violates the protocol invariant but must not crash the MDS: it depends on client replay state and index integrity. Reject the replay (the client drops the lock; evict the client if needed), log it with CERROR (an LASSERT may remain in debug builds), and handle the file conservatively. If an active-epoch record exists for a different epoch, the lost-participant rule applies; if none exists, open a recovery rollup that stales every mirror other than the primary. Never create the missing record or treat the file as clean.

Storage. Dedicated local DT indexes are the preferred candidate, not yet a chosen format. DT index operations (lustre/include/dt_object.h:1532) provide keyed lookup, declared insert/delete under a transaction handle, and iteration on both backends (local index creation (lustre/obdclass/local_storage.c:514); ldiskfs IAM (lustre/osd-ldiskfs/osd_handler.c:6029); ZFS ZAP (lustre/osd-zfs/osd_index.c:1769)), with fixed-size unique keys. Constraints found in the source (Lustre 47638add78):

  • Sharding: LFSCK’s hashed local trace indexes (lustre/lfsck/lfsck_lib.c:2716) are a precedent for FID-hashed shards in dedicated IWM objects (not shared OI/LFSCK records).
  • No ordered scans: ZFS iterator seek (lustre/osd-zfs/osd_index.c:1870) is not implemented for nonzero keys and index negotiation (lustre/osd-zfs/osd_index.c:2024) rejects range and variable/non-unique formats, so recovery scans shards and groups records in memory.
  • Same-transaction updates: index mutations and the layout xattr can share one local backend transaction (MDD orphan insertion (lustre/mdd/mdd_orphans.c:82) is a caller-owned precedent), but MDD layout change (lustre/mdd/mdd_object.c:3543) owns its transaction today and must be extended.
  • LLOG: catalog append (lustre/obdclass/llog_cat_server.c:443) can join a caller transaction, but record cancellation (lustre/obdclass/llog.c:198) starts its own, so LLOG would need extra bookkeeping to meet the atomic boundaries.

Execution failures within a transaction. A shared transaction handle groups updates but does not undo them if a later mutation fails (ZFS stop (lustre/osd-zfs/osd_handler.c:280) commits an assigned transaction; ldiskfs stop (lustre/osd-ldiskfs/osd_handler.c:2291) closes the handle without rollback), and declarations do not make execution infallible (DT execution fault hooks (lustre/obdclass/dt_object.c:1756)). DT insertion does not overwrite (IAM (lustre/osd-ldiskfs/osd_iam.c:2438) rejects an existing key; ZFS uses zap_add_uint64), so error evidence must be added monotonically, never deleted and rewritten. Only a real commit callback (not DCB_TRANS_STOP) may satisfy the grant and error-ACK durability gates; target last-committed handling (lustre/target/tgt_lastrcvd.c:937) is a precedent for refusing to advance on commit error. Proposed execution order, stopping at the first failure (lustre-design-docs-od5.61):

  1. Opening: active-epoch record, then participation, then the opening layout; AW authority becomes usable only after all of it is durable. A join records only participation.
  2. Error-bearing cancellation: add every reported error before removing participation; acknowledge only after the whole handoff is durable. On partial failure keep participation, errors and the pending report for idempotent retry.
  3. Close: write the closing layout outcome first, then delete consumed errors, then the active-epoch record. Never continue cleanup after a failed outcome write or report success on an execution or commit error.

An abstract check of 20 failure points in this order found no lost evidence, but six close cases leave a closed layout with residual tracking (next item). It assumes each mutation applies wholly or not at all; it is not a backend fault-injection test.

Open items.

  • Question for Andreas Dilger: recovery-phase placement. Should recovery rollups run as one additional final phase of recovery, before normal service resumes (Patrick’s tentative preference), or as ordinary operations after recovery, with their locks blocking only the affected files? Includes the recovery hook, when cancellation and drain are available, and service-resumption timing.
  • Lock progress during recovery (lustre-design-docs-od5.30). A final-phase rollup cannot just call the ordinary close worker: LDLM reprocessing is suppressed during lock recovery (lustre/ldlm/ldlm_lock.c:2401) and ordinary RPCs wait for a full import (lustre/ptlrpc/client.c:879), so client CANCEL is delayed during replay. Define how LAYOUT/AW callbacks, cancellations, reprocessing, error persistence and drain make progress in that phase without admitting ordinary IO, keeping LAYOUT EX → AW EX.
  • Decision needed (Patrick): failure-only close residue. With outcome-first close ordering, a cleanup deletion can fail after the closing layout has executed and committed, leaving a closed layout plus old tracking. Proposal: recognise a durable completed outcome for the same epoch and treat the surviving records as cleanup-only, never re-running outcome selection; reconcile before any later epoch or conflicting layout change, and without unambiguous recognition keep the evidence and block conflicting progress. RDONLY, a current-FID lookup or a missing lock is not enough to recognise it; no outcome or epoch tag encoding is chosen. The alternative is a proven atomic or rollback primitive.
  • Detailed design (lustre-design-docs-od5.21, lustre-design-docs-od5.61): physical schema and key encoding; stable client identity across reconnect; duplicate or reacquired grants; replay matching; distinguishing a safely released participant from a lost one; index bootstrap; shard count and versioning; iteration and update races (never hold an index iterator or lock across distributed-lock waits; verify ZAP cursor behaviour under mutation and scan completeness); memory bounds; both-backend crash and fault-injection tests. Per-stripe error records remain TBD.

Flush Semantics on Lock Release

AW cancellation drains admitted modifying IO, including operations that have not dirtied anything yet and DIO with no cached pages, then performs a final file-wide flush of all dirty data, including data never submitted in an RPC, and resolves every outstanding mirror outcome (the release barrier, §2.2.2). Writeback may start earlier, but a dirty-page scan taken before admitted IO has drained is not the barrier, because that IO could still dirty data. AW is never released while dirty data, or an admitted operation that could produce it, remains.

The drain runs under the protection the client already holds. It takes no new layout, AW or extent lock, and existing object mappings and references stay usable until the work is resolved. Local synchronization is allowed; a new distributed-lock dependency in the cancellation path is not.

The client must associate admitted modifying IO, PW write authority, dirty pages, in-flight RPCs and commit/error tracking with the current epoch, across buffered syscall return and layout refresh. RPC reference counts or lo_active_ios alone do not cover idle dirty pages or cached write permission. A file-wide flush avoids needing per-page epoch ownership just to choose what to flush.

There are two release paths with the same completion condition:

  • Voluntary release (idle timer or an error, no conflicting request). There is no server-held layout EX, so the client gates new modifying IO and write faults locally and revokes writable mappings with unmap_mapping_range() (§2.2.7.3), accounting for faults already past admission at the locked-page/PTE handoff. Dirty cache and existing PW, object and AW protection are kept. It then flushes, passes the release barrier, and releases with its error report. A terminal failure skips the idle timer. The last voluntary release triggers rollup, and an error-bearing one triggers it immediately (§2.2.4).
  • Forced revocation (the MDS holds layout EX and requests AW EX). Layout revocation has already blocked new IO and unmapped writable mappings; a cached AW or PW lock is not a bypass. The client starts draining and flushing at once. The blocking AST completes only when admitted IO has drained, the final flush is done, successful mirrors are durable and failed-mirror IO is resolved; a timer expiring or an error bit being set does not complete that work. Finite fast- and slow-fail budgets apply during forced closure; wait-indefinitely stays unbounded (administrative abandonment is the separate way to act on its outstanding work). An IO that must restart for a fresh layout leaves the old drain before waiting for that layout, while its dirty data stays covered by the file’s AW obligation.

Neither path lets late retries escape the closed epoch and interfere with later IO, resync or mirror reuse, and neither requires a conclusively failed mirror to commit.

Release does not need to discard cached data. unmap_mapping_range() removes file-backed PTEs without discarding dirty pages; vvp_prune() and fake layout invalidation are not used just to revoke mapping authority. (Reusing the complete PW cancellation path would discard pages as a side effect, since osc_lock_flush() writes back and then discards.) Writable mappings may not outlive AW coverage. A secondary failure alone never discards data when the primary succeeded; primary and writeback errors keep normal handling.

Evicted clients cannot be relied on to cancel or flush; they are handled by the lost-participant rule and version fence in §2.2.4, not by a full-extent PW sweep.

Existing paths to reuse, with their limits (47638add78): layout invalidation only invalidates and unmaps; applying a changed layout (cl_conf_set() / lov_conf_set() → lov_layout_change() → cl_object_prune() → vvp_prune()) flushes with CL_FSYNC_LOCAL, which waits for writeback, not commit, and then tears down the cache. The CIT_FSYNC path skips layout refresh in vvp_io_init(), and cl_sync_file_range() can use existing object mappings with ci_ignore_layout.

Open items (lustre-design-docs-od5.8 P1–P3, lustre-design-docs-od5.36): exact IO reference boundaries and the final-flush barrier; the voluntary-release admission gate; mmap fault/unmap races; preservation of old object and PW references through pruning and reconfiguration; local configuration-lock and fan-out callback dependencies (no local wait cycles).

AW Lock vs Layout Lock Interaction

The layout lock tracks what the layout is (stripes, components, mirror membership); the AW lock tracks the write epoch (who is writing and when the epoch closes). They are separate locks that interact closely.

Lock order: LAYOUT before ACTIVE_WRITERS, everywhere. The client’s layout hold is soft; the server takes EX on both, layout first, for any operation needing both. There is no exception.

  • Client admission. Obtain LAYOUT, acquire or reference AW at the top of the modifying IO, then check that the LAYOUT grant is still valid and that the layout and AW epoch are compatible, before any PW use or dirtying. If the layout was lost, drop the IO’s AW reference (returning an unused new grant through normal release) and retry from LAYOUT; never wait for a fresh layout while holding the old IO’s AW reference. Old dirty data keeps the file’s AW protection until the normal drain. The same sequence applies to cached AW. Files that do not need immediate mirroring take no AW.
  • IO restart on a layout change. An ordinary layout change within an epoch revokes LAYOUT but not the AW grant, which can stay cached; the restarting IO drops its own AW reference and readmits. Dirty data keeps its epoch association.
  • Epoch close and administrative operations (resync, mirror replacement or addition, IMMEDIATE membership changes): layout EX, then AW EX, with exclusion held through the operation (§2.2.4). Layout EX alone is not a flush barrier. Structural changes require full page-cache teardown; flag-only changes can use STATE_ONLY_CHANGE (§2.2.7.3). Membership changes follow the conditional rules in §4.2.
  • Leaving IWM. Removing immediate mirroring is an epoch boundary: the old PW, dirty-data and AW obligations are resolved before delayed-write authority is granted, and later writes under that layout take no AW. A changed layout never retires pending obligations by itself.
  • Non-IWM access follows the capability gate in §2.4.6. Reads by capable clients never force closure. Optional primary-only writes reuse ordinary FLR write intents, closing any open epoch with LAYOUT EX → AW EX first.

Forced close, from the client’s side.

  1. The MDS obtains layout EX. Clients’ layout holds and writable mappings are invalidated, and no new IO can obtain a valid layout.
  2. The MDS requests AW EX. Each client drains the IO already admitted under AW, even IO that has not dirtied anything.
  3. The client performs the final file-wide flush under its existing protection and mappings, with no new locks, and passes the release barrier.
  4. It reports its outcomes and releases AW. When every holder has released, the MDS holds both EX locks and finishes the close or administrative operation. Lost clients are handled by the fence in §2.2.4.

The layout gate stops new IO; AW cancellation finishes old IO. The closing flush never refreshes the layout or readmits itself behind the server’s layout EX, which removes the layout-EX / AW-cancellation / layout-CR circular wait.

Epoch entry reply: updated layout plus a replacement LAYOUT lock. The opening AW request changes the layout under MDT layout EX, which revokes the requester’s soft layout lock. The AW enqueue reply therefore returns the updated layout and a replacement LAYOUT grant together with the AW grant. These are two independent locks in one reply, not a merged-bit lock and not LDLM lock conversion; returning layout bytes without a grant would not do. The MDT makes the epoch durable under LAYOUT EX, then hands over the replacement LAYOUT grant followed by AW, serialized so that no incompatible transition can slip in between and make the pair obsolete (sharing one reply does not by itself guarantee this). The client refreshes the layout under the replacement grant and validates that grant, not the revoked one, before PW use or dirtying; if the grant is cancelled before validation, it retries normally even though AW arrived. A join of an unchanged epoch can keep a still-valid layout grant.

This reuses FLR’s existing handoff: ordinary write intents already change the layout, return the layout grant via mdt_intent_lock_replace() with an EX-to-CR downgrade in mdt_intent_layout(), and let the client apply it through ll_layout_refresh() / ll_layout_lock_set() (47638add78). Only the independent AW grant is new.

Open items (lustre-design-docs-iwm.3.2, lustre-design-docs-od5.8 P1–P6): the reply representation for both grants; cancellation, partial or lost replies, failure, retry and reconnect handling; ordered-handoff serialization; exact admission hook placement (ll_file_io_generic() is the likely ordinary-write hook; all paths that bypass it need equivalent coverage, §2.3); local lock dependencies during pruning and fan-out.

Why Separate Locks

AW is a separate IBITS bit, not a mode or flag on the layout lock, for three reasons:

The layout lock is light; the AW lock is heavy. The layout lock can be revoked at any time without flushing; the client simply restarts IO under the new layout, which matters for layouts that change often (PFL component instantiation, SEL). AW revocation forces a full flush and durable commit. On one lock, every layout revocation would become an expensive synchronous flush, and a sequentially written many-component PFL file would close its epoch at every component boundary.

The epoch must span layout changes. Component instantiation and SEL extension revoke LAYOUT but should not close the epoch: the participants and the write session are unchanged. Because ACTIVE_WRITERS is a separate bit, layout EX does not revoke it. The epoch closes only when the MDS takes AW EX (errors, resync, administration) or every client releases AW.

No MDT/OST dependency cycle. AW is held across OST IO, and an AW EX request waits for that IO while holding layout EX, so cancellation must never need a layout refresh; it uses existing mappings and protection. Local configuration guards, object pruning and asynchronous fan-out callbacks still need checking for local cycles.

Combining both bits on one lock with conditional flush semantics was rejected: it reinvents AW as a layout-lock mode and complicates every layout-lock user. LDLM lock conversion (take both bits, later drop LAYOUT) could save a round trip but depends on a lightly exercised feature with subtle recovery behaviour; it remains a possible future optimization. The entry reply above already returns both grants in one round trip.

Behavior During Layout Changes

This covers non-destructive layout changes within an epoch (component instantiation, SEL extension, flag changes that keep the participant set), where only the layout lock is involved and AW is untouched. It does not cover destructive changes, IMMEDIATE membership changes or epoch boundaries.

Component instantiation covers every participant. A write intent instantiates the needed components of every participating IMMEDIATE mirror, each according to its own layout, before fan-out to them; this extends the MDT’s existing primary-mirror instantiation and involves no client-side component creation. A secondary instantiation failure is a secondary modifying-IO failure: that mirror ends the epoch STALE, and a successful primary write is not turned into an application error. A primary instantiation failure follows the primary failure rules. Resync must also instantiate the components it needs; if that keeps failing, resync fails and the mirror stays STALE until a retry succeeds. Partial-success replies and their error recording are implementation work (lustre-design-docs-od5.49.12).

When such a change happens while a client holds both locks:

  1. The server takes LAYOUT EX, revoking every client’s layout lock.
  2. The client’s layout blocking AST returns immediately, with no flush and no AW involvement, as today.
  3. OST writes already in flight complete normally. An ordinary generation change inside a write era only widens the OST’s accepted version range, so they are not rejected (§2.2.4).
  4. An IO that needs the new layout drops its AW reference and restarts from the top, then waits for the fresh layout.
  5. The AW grant stays held or cached, not pinned by the restarting IO; old dirty data and the epoch are untouched.
  6. On restart the IO reacquires LAYOUT (through a write intent if needed), references the AW grant and validates the layout before continuing in the same epoch. If AW was meanwhile cancelled for closure, the old drain finishes first and the IO retries admission.

So layout changes are not epoch boundaries. Errors accumulate across them, and the epoch close handles all of them.

Layout Lock and Page Cache Interaction

Why layout changes are expensive today. When a client reacquires its layout lock after revocation, ll_layout_lock_set() applies the new layout via lov_conf_set(OBJECT_CONF_SET), and lov_layout_change() tears down and rebuilds the lov_object, destroying every sub-object (osc_object). Before teardown, cl_object_prune() → vvp_prune() flushes all dirty pages with cl_sync_file_range() and then drops the whole page cache (ll_truncate_inode_pages_final()): each cached page is attached to one sub-object and cannot outlive it. That is necessary for structural changes. Delayed-write FLR changes layouts only on idle files, so the cost is small. IWM changes them on files under active IO with hot caches on many clients, so dropping the cache at every epoch transition would make buffered IWM IO unacceptably slow.

Non-destructive changes keep their sub-objects: FLR state transitions (RDONLY → WRITE_PENDING, INFLIGHT and STALE flag changes) change only per-component flags, and component instantiation (PFL, SEL) only adds sub-objects. For these, rebuilding the lov_object is unnecessary, but not every cached page can be kept.

Read-eligibility rule. Each cached page belongs to one mirror’s sub-object, and clients choose their read mirror independently (per-client hash plus preference, lov_object.c:770-804), so pages cached from a non-primary mirror are normal. During an epoch, writers lock only the primary and secondary BRWs carry no client lock, so nothing would revoke another client’s pages on a secondary that has just become INFLIGHT; they would go stale. A writer that found such a page would dirty a page owned by a secondary sub-object, which lov_io_sub_init asserts against (lov_io.c:111-116). Today the full prune hides this. So when a state-only change is applied, every mirror that goes from readable to not readable has the pages and extent locks on its sub-objects discarded; mirrors that stay or become readable keep their cache:

  • Epoch entry: the secondaries become INFLIGHT, so their sub-objects are pruned; the primary’s pages survive.
  • Epoch close and component instantiation: no prune, because INFLIGHT mirrors are never read and so have nothing cached.
  • A primary replaced at close loses eligibility, so its sub-objects are pruned.

This needs no new wire data. The prune already runs at layout refresh, not at cancellation: cancellation only invalidates and unmaps (vvp_conf_set(OBJECT_CONF_INVALIDATE)), and the refresh holds both the old and new layout, so the client compares per-mirror eligibility and prunes only the affected sub-objects (lov_delete_raid0 already prunes per sub-object). Between invalidation and refresh, fast read cannot serve stale pages, because ll_read_from_cache declines while the layout generation is CL_LAYOUT_GEN_NONE. Server-side locking on secondary BRWs was considered instead and rejected: it cannot cover the writing client’s own secondary-owned pages, which can be dirtied before any secondary BRW is sent.

The STATE_ONLY_CHANGE flag. The MDS sets a transient flag in the layout when the latest layout_gen bump was non-destructive, only for clients that advertise support through an OBD_CONNECT2 bit. On reacquiring the layout lock:

  • If the client’s layout_gen is exactly N-1 and the new layout carries the flag, it updates flags (and adds sub-objects for new components) in place in the existing lov_stripe_md, skips the whole-file cl_object_prune(), and applies the read-eligibility rule.
  • Otherwise (flag absent, more than one version behind, or a destructive change) it does the full teardown as today.

The flag describes only the most recent transition, hence the N-1 rule. The optimization is purely opportunistic: old clients never see the flag, clients more than one version behind ignore it, an MDS restart loses it, and its absence for any reason means a full teardown. It is separate from the safe-selection capability and from epoch serialization.

Prior art. CSDC added LAYOUT_INTENT_CHANGE for flag-only layout updates (LCM_FL_INCOMPRESSIBLE; EX-8355, Gerrit 54248). The EX patch defines the wire pieces (LAYOUT_INTENT_CHANGE, LAIF_INCOMPRESSIBLE, OBD_CONNECT2_UPDATE_LAYOUT) and an MDS handler (lod_declare_layout_minor_change()); master (47638add78) has only the wire definitions. That patch still bumps layout_gen and still tears down the client cache, avoiding the cost by deferring the change until no layout lock is held; IWM instead makes the client’s reacquisition cheap. The wire definitions could carry the IWM transitions. Existing client behaviour to build on: lov_conf_set already skips the prune when generation, flags and first-entry pattern are unchanged, and returns -EBUSY while lo_active_ios > 0, deferring a layout change during active IO (lov_object.c:1500-1515).

Mapping revocation reuses existing code. vvp_conf_set(OBJECT_CONF_INVALIDATE) already calls unmap_mapping_range(inode->i_mapping, 0, OBD_OBJECT_EOF, 0) (source (lustre/llite/vvp_object.c:110)). It removes file-backed PTEs across processes, not VMAs or dirty page-cache contents, and private COW data is untouched; a later access faults again. Voluntary and idle AW release use the same primitive (directly or through a factored helper) without clearing a valid layout or pruning the cache; forced closure gets it from layout invalidation. Unmapping does not prevent refaults (Linux allows remapping races (Linux v6.12 mm/memory.c:3556)), so it is coupled with the local AW admission gate, the drain of admitted work and normal page/folio/PTE synchronization, so that no old writable mapping survives the final quiescence point; the final flush follows, under the old PW, object and AW protection. Write refaults go through normal LAYOUT → AW admission; a cached PW lock alone cannot restore write permission. No new VMA tracking or distributed lock is needed.

Open items (lustre-design-docs-od5.36, lustre-design-docs-iwm.3.3): __ll_page_mkwrite() finishes its cl_io but leaves the page locked, and ll_page_mkwrite() returns VM_FAULT_LOCKED (Lustre fault path (lustre/llite/llite_mmap.c:137)); Linux (Linux v6.12 mm/memory.c:5023) installs the PTE afterwards, so an AW IO reference count does not cover that handoff. Integrate and test the locked-page/PTE interlock with AW cancellation (including a fault paused at that point, cached-PW refaults and continuously written shared mappings), and the pcc_page_mkwrite() early return. Implement the per-mirror prune in STATE_ONLY_CHANGE.

Client Implementation

Admission coverage. Every path that can dirty file data or modify OST objects must take AW admission (LAYOUT, AW reference, layout revalidation) before it does so, or be denied for IWM files. At 47638add78 the paths are:

Path Notes
ll_file_io_generic() (read_iter/write_iter, AIO, io_uring, splice_write, copy_file_range via the splice fallback) The likely main admission hook
Tiny write (ll_do_tiny_write(), on by default) Writes into an already-dirty page with no cl_io and no new lock; the page is already under the epoch’s AW obligation, but the path must respect the voluntary-release admission gate
Hybrid IO Switches large buffered IO to DIO; DIO rules apply (client lock, §1.2)
mmap write faults (ll_page_mkwrite()) Admission at fault entry; locked-page/PTE handoff in §2.2.7.3
PCC read/write/splice/mkwrite early returns Bypass Lustre’s IO stack; must not dirty an IWM file without admission
cl_setattr_ost() (truncate, time setattr) and cl_falloc() Admission like writes (§2.3.2)

Fast read serves already-cached pages without cl_io; it is read-only and declines while the layout is invalidated, so it needs no AW. Verifying this coverage is tracked in lustre-design-docs-od5.49.14 (mmap in lustre-design-docs-od5.36).

Write Duplication via BRW Page Fan-Out

The client duplicates every write to all participants (other modifying operations are in §2.3.2). The design choice is the layer at which duplication happens.

IO-level duplication is not viable. For buffered IO it would mean sending pages to the secondaries when the IO is created, effectively direct IO to the secondaries, because a page can be cached and aggregated for only one mirror. That loses asynchronous writes and write aggregation on every secondary. (For DIO it would work, but is not sufficient on its own.)

BRW page fan-out (selected). Duplication happens at the BRW layer, after the primary’s write is fully prepared (pages assembled, bounce pages allocated), so buffered and direct IO are handled the same way: buffered writes aggregate normally in the primary’s page cache and are duplicated when the BRW is built, and DIO is duplicated from the same BRW page array. When the primary’s OSC has built its BRW, it calls up to LOV, which rebuilds each secondary’s RPCs from those pages through that mirror’s own stripe mapping, so mirrors may have different layouts (stripe count and size, components, compression). The call-up runs in the writeback/ptlrpcd work path, where sleeping is allowed, but it allocates and builds IO inside writeback, so memory use and lock dependencies there need care. Duplicating the primary’s RPCs verbatim (RPC duplication) is simpler but requires identical layouts on every mirror, which real deployments cannot guarantee (§4.2).

Qian Yingjin’s prototype (LU-13643, Gerrit 63244) demonstrates the hook point and per-mirror rebuild, including heterogeneous layouts. It is a feasibility demonstration, not an implementation to reuse as-is (patchset 1, 2026-01, no human review, broad CI failures). Where it differs from this design:

Prototype behaviour (Gerrit 63244 patchset 1) Required by this design
Primary BRW keeps its RPC slot until all secondaries finish (whole interpret deferred) Free the slot at the primary’s reply; defer only extent/page completion, bounce pages and error collection
Wraps the primary’s encrypted bounce pages as clear text for secondaries Reuse bounce pages when compression settings match; otherwise compress and encrypt from clear pages
Secondary errors dropped; only the primary’s result survives Collect per-mirror errors into the AW cancellation report (failed mirror IDs plus epoch ID)
Resend path can free or reuse the shared completion context Resend-safe refcounting of the shared completion
Every FLR write fans out; no IMMEDIATE/STALE gating Fan out only to the epoch’s participants
Secondaries write without grant once grant runs out Handle secondary ENOSPC/EDQUOT when the write is attempted, as a secondary error
Primary extent lock release does not wait for secondaries No primary extent lock release while a covering secondary attempt is unresolved, on every release path
Secondary DIO flag passed from the server-lock flag Secondary DIO RPCs carry the same OBD_BRW_SYNC semantics as the primary (§2.2.2)
Secondary writes carry OBD_BRW_NOLOCK (no client or server lock) Acceptable given the per-mirror prune on read-eligibility loss (§2.2.7.3)

Page Consistency During Write Duplication

The primary’s BRW pages must stay valid and unmodified until every mirror attempt has completed or been safely resolved. Three threats:

  • Source page modification. For buffered IO, another write to the same pages while secondary RPCs are in flight would send the secondaries different data from the primary. For DIO, changing the user buffer during a write is already an application error, but every mirror attempt must keep its own references to the source; a BRW page array is not a private copy.
  • Competing IO on the same client. An overlapping write must be serialized against duplication in flight, so the secondaries see the same order as the primary.
  • Competing IO from other clients (ordering invariant). A primary extent lock is never released while any secondary attempt covering its pages is unresolved. Otherwise client A could release its lock with a secondary write still in flight, client B could take the lock and write the same range to every mirror, and B’s secondary write could land first: the primary ends up A-then-B and the secondary B-then-A, with no error recorded. This holds on every release path (blocking-AST cancellation, LRU cancellation, early lock cancellation, and DIO paths that drop their lock early).

Every mirror RPC for a page must complete or reach a resolved terminal failure, so outcomes are tracked per page or per RPC across mirrors. A successful reply still needs the release barrier’s durability check. A failed mirror owes no successful reply, but its failure is recorded and its IO can no longer affect buffer lifetime or later epochs and resync.

RPC slots and completion are separate. The primary BRW releases its RPC slot when its own reply arrives, as today. Only extent and page completion (ending writeback), bounce-page release and per-mirror error collection wait for every mirror. Holding the slot until the secondaries finish could deadlock: two OSTs, each primary for some files and secondary for others, could fill their slots with primaries waiting on secondaries queued behind them. With the split, a secondary RPC waits only for its own reply. The request stays on the import replay list until committed, independently of the slot. Keeping the primary extent in its in-RPC state until all mirrors finish preserves the ordering invariant. In-flight duplicated data is bounded by kernel writeback throttling and each OSC’s own slot limit.

Buffered IO Completion Semantics

Buffered write() stays asynchronous: it may return once data is in the client cache, before any mirror RPC is sent, with no stable-storage promise and normal aggregation; explicit synchronization has its own contract (§4.1). AW coverage starts at admission and continues at file level through syscall return, delayed writeback and commit tracking; reusing a cached PW lock obeys the same rule, and a layout refresh never leaves accepted data without an epoch.

The fan-out barrier applies to writeback completion, not syscall return: page stability and overlapping-write ordering are kept until every mirror attempt is resolved, including against stores through already-writable mappings (a page lock alone does not stop those). Page completion is separate from durable commit, which the release barrier handles.

Memory-mapped writes use the same writeback fan-out, but write permission is granted earlier: the write fault validates the layout and takes AW at entry. Forced closure invalidates the layout and unmaps writable mappings before requesting AW cancellation; voluntary release unmaps with the local admission gate (§2.2.7.3).

Direct IO Completion Semantics

DIO has no page cache, so page locks cannot serialize it. In v1 a DIO waits for every mirror RPC to complete or reach a resolved terminal failure before returning and releasing its buffer and ordering protection, under all three secondary policies (wait-indefinitely has no timeout). The return code is the primary’s; secondary failures go into the cancellation report. A successful DIO is durable on the primary, and every participant’s updates are committed or recorded as failed, regardless of O_SYNC/O_DSYNC (§2.2.2); it does not wait for other writers or for the epoch to close. Returning while secondary work continues would need protected data copies and ordering against later IO, and is future work.

Implications for Erasure Coding

The BRW page fan-out and page consistency mechanisms are intended to carry over to immediate EC writes, though significant design work remains. EC requires absolute consistency between source data pages and computed parity pages — primary locking provides ordering, and the tree lock (rounded to raidset-aligned extents) would ensure source pages are stable during parity computation. Tentatively, EC parity pages would be dispatched to their target OSTs via the same LOV-level call-up used for IWM secondary mirror fan-out, reusing the completion barrier infrastructure. Parity computation timing, partial-stripe writes, and the interaction between EC raidset geometry and the fan-out path remain to be designed.

Compression and Encryption Across Mirrors

Ideally we would not have to compress or encrypt data more than once when sending to multiple servers Compression is per component in the layout (lcme_compr_type, lcme_compr_chunk_lum_bits), so mirrors can differ in algorithm and chunk size, or be compressed and uncompressed. Because fan-out rebuilds each secondary’s RPCs through its own layout, each secondary’s OSC compresses with its own settings and heterogeneous compression needs no special protocol. One requirement follows from how client-side compression (CSDC) works: chunk alignment is done by the client at IO start, not by the OST. For an unaligned write, lov_io_init computes the leading and trailing partial chunks, the client reads them into cache, and vvp_io_rw_lock rounds the extent lock to whole chunks (TLC tree, CSDC series). That rounding uses the selected mirror’s chunk size today; for IWM it must use the largest chunk size among the participants. Chunk sizes are powers of two, so aligning to the largest aligns to all, and CSDC already computes that maximum across layout entries (ci_max_compr_chunk_bits). The cache then holds whole chunks for every mirror, and no secondary needs a read-modify-write inside writeback.

Bounce-page reuse. Fan-out runs after the primary has built its bounce pages. Encryption IVs come from the logical file block, not the OST object (osc_request.c:1416-1418 at 47638add78), so encrypted pages are the same for every mirror. A secondary whose compression settings match the primary’s, or where neither is compressed, reuses the primary’s bounce pages as-is; one with different settings compresses and then encrypts from the clear pages itself. Compression work is therefore done once per distinct setting.

Other Modifying Operations

Truncate, hole punch, fallocate and some setattr variants also modify OST objects and must be applied to every participant. They carry no data pages, so they fan out at the LOV sub-io level instead of through BRW fan-out.

Operation Classification

Duplicated to every participant (OST object operations):

  • Truncate — OST_PUNCH over _size, EOF), mapped to the affected stripes of each mirror. ll_setattr_raw() first sends an MDS RPC (the MDS approves the size change, including ETXTBSY), then cl_setattr_ost() performs the OST side; only the OST side is duplicated.
  • Fallocate — preallocation (mode 0, KEEP_SIZE), hole punch (FALLOC_FL_PUNCH_HOLE) and ZERO_RANGE (ll_fallocate() supports these; other modes return -EOPNOTSUPP), all through OST_FALLOCATE.
  • Timestamp setattr (ATTR_MTIME, ATTR_ATIME, ATTR_CTIME) — via osc_setattr_async.

Not duplicated by the client:

  • chmod stays on the MDS. chown, chgrp and project ID are pushed by the MDT itself to every stripe of every instantiated component of every mirror, including STALE ones, asynchronously through the OSP llog, for quota accounting (LA_REMOTE_ATTR_SET, lod_attr_set at 47638add78). That already covers all mirrors and changes only ownership metadata, so no client fan-out or epoch interaction is needed.
  • setstripe and other layout changes interact with IWM through layout and epoch coordination, not duplication.

FIEMAP is a query, not a modifying operation: for IWM files it reports the primary mirror’s allocation only, takes no AW and does not close the epoch. Existing FLR FIEMAP ([source (lustre/lov/lov_object.c:2148)) iterates components across all mirrors, so IWM must select the primary explicitly; FIEMAP_FLAG_SYNC keeps its normal writeback behaviour (source (lustre/llite/file.c:3250)). Non-IWM FLR behaviour is unchanged (implementation: lustre-design-docs-iwm.10.1).

LOV Sub-IO Fan-Out

In FLR today these operations use the CIT_SETATTR path: LOV selects one mirror (lov_io_mirror_init sets lis_mirror_index), creates sub-ios only for its stripes, and a write intent stales the other mirrors; resync copies the result later.

For IWM they execute on every participant immediately. Clean non-immediate mirrors are excluded, with their affected components marked STALE through the MDS write intent before the operation runs. The fan-out point is lov_io_iter_init: it creates sub-ios for the overlapping stripes of every participant, each calling the per-stripe RPC (osc_punch_send for truncate, osc_fallocate_base for fallocate and punch, osc_setattr_async for timestamps). Today lov_foreach_io_layout(ind, lio, ext) takes the mirror implicitly from lio->lis_mirror_index (via lov_io_layout_at()); for IWM the iteration covers every participant. Offsets are translated per mirror (lov_size_to_stripe), so heterogeneous layouts map the same file offset to different object offsets correctly. Since the parameters are known when the cl_io starts, all sub-ios are created up front, with no call-up from OSC.

AW Lock and Epoch Interaction

These operations are admitted exactly like writes (layout, then AW reference, then layout revalidation) and run inside the epoch. Per-mirror errors go into the cancellation report: if a truncate succeeds on the primary but fails on a secondary, that secondary keeps the wrong size and becomes STALE at close, and resync copies the primary.

Before a truncate or punch, the client flushes dirty pages in the affected range; under IWM that flush goes through BRW fan-out, so earlier buffered writes reach every mirror before the truncate sub-ios run.

FLR’s write intents (LAYOUT_INTENT_TRUNC, LAYOUT_INTENT_WRITE for fallocate) remain in v1, both for component instantiation and staling of non-immediate copies and for non-IWM clients where they are allowed (optional primary-only writes, §2.4.6). Folding them into AW admission is a possible future optimization, but it would still have to stale omitted non-immediate components before the operation.

Open item (A7): the exact range-flush ordering, completion and error contract for truncate, punch and fallocate (§4.1).

Implications for Erasure Coding

The LOV sub-io fan-out for truncate/punch/fallocate extends naturally to immediate EC. Where IWM creates sub-ios per-mirror-per-stripe, EC would create sub-ios per-parity-group. Truncate and punch are particularly relevant for EC because they can create partial-stripe boundaries that require parity recomputation — a stripe that was fully allocated may become partially punched, requiring the parity stripe to be updated. The sub-io fan-out provides the dispatch infrastructure; the parity computation logic layers on top.

Error Management

Secondary Timing Policies and Administrative Abandonment

Three secondary IO policies, selected per client mount (not per file or in the layout), with finite slow-fail as the default:

  1. Wait indefinitely: keep retrying and recovering with no timeout. Outstanding work can survive an arbitrarily long outage; an unrecoverable error or administrative abandonment still ends it.
  2. Slow-fail: a longer finite budget; when it expires, safely resolve outstanding activity and record the secondary failed.
  3. Fast-fail: the same model with shorter timings; it gives up sooner on a transient outage, staling the mirror and requiring resync.

All three share the same durability, terminal-failure and safe-resolution rules; slow and fast differ only in timing. Different mounts may choose differently without changing shared mirror-state rules. A finite budget must reach every underlying wait: submission, reconnect, recovery and replay, the modifying RPC, and commit/sync including OST_SYNC during forced closure. Per-RPC timeouts that restart indefinitely do not bound it. Expiry starts terminal resolution; it does not promise that the syscall or fencing finishes by then. Fast-fail is the first development milestone, for predictable fault-injection tests; slow-fail is required for the first supported release.

Terminal errors. A retryable event is not a mirror error if recovery completes the operation and its durability. An error becomes terminal when a finite budget expires, normal processing returns a terminal failure, or an administrator abandons the secondary, at any stage (submission, transport, the RPC, or commit/sync). It then stays recorded for the epoch, and the client promptly resolves its IO and releases AW with the report; the MDS closes the epoch and marks the mirror STALE. A late success of one RPC cannot clear the epoch’s mirror failure.

Safe resolution. A timeout, administrative request or error bit does not by itself make outstanding IO safe: retries must be stopped and activity completed, cancelled or fenced so that it can no longer touch source buffers or interfere with later epochs or resync. Buffer and ordering protection stays until then. No policy weakens the durability required of mirrors reported clean.

Space and quota. Secondary ENOSPC/EDQUOT is handled when the secondary write is attempted, as a secondary error (whole-mirror STALE, primary result unaffected). The primary’s grant and buffering are unchanged, and there is no advance reservation on the secondaries in v1. Implementation: lustre-design-docs-od5.49.8; advance reservation as a later improvement: lustre-design-docs-od5.56.

Application results. A secondary failure never turns a successful primary result into an application error, for DIO, fsync(), fdatasync(), O_SYNC or O_DSYNC. All policies keep the completion barriers: DIO and explicit durability requests need committed-or-recorded-failed outcomes for their scope, and buffered writes keep page protection during fan-out. Slow-fail can stall DIO, sync requests, writeback or epoch close longer than fast-fail; wait-indefinitely can stall them without limit. None waits for epoch close. Primary failures follow the separate primary rules (§2.4.4).

Changing the policy affects new IO only; operations already waiting keep their policy and timings, and are not woken or converted. A change never clears accumulated errors or releases outstanding protection, and does not alter primary IO behaviour.

Administrative abandonment is part of declaring an OST dead: a client-side action targeting that OST that reaches its outstanding secondary work, including wait-indefinitely IO, invocable on one client or triggered on all clients when an OST is declared dead cluster-wide. It is not a per-file protocol or a policy change. Affected files then follow the normal terminal-error and epoch-close rules.

Terminal secondary failures reduce redundancy while the primary stays usable; failed mirrors appear in the changelog and are restored by normal FLR resync. Slow-fail trades longer waits for a chance to avoid that; fast-fail accepts it sooner. Returning from DIO while secondary retries continue, or replicating after completion, would need protected source copies and ordering against later writes, and are future designs.

Open items (lustre-design-docs-od5.33): numeric timings, timer start and accounting, control names, and the policy sampling point for new IO; the abandonment interface and target identity, distribution and acknowledgement, and how disconnected, reconnecting and new clients learn of it (sending a notification is not proof of cluster-wide completion); the dead-OST state’s lifetime, fencing and reactivation, so abandoned work cannot resume against resync or new epochs; an OST that also holds primary objects (primary failures must not be hidden as secondary-only); delivery milestone.

Failure Handling

Mirror States: STALE and INFLIGHT Flags

At epoch entry (the first AW request), the MDT selects the primary by the usual policy, moves the file to WRITE_PENDING and sets INFLIGHT on every other participant. Entry does not clear pre-existing STALE or make an unsynchronized copy eligible, and non-immediate mirrors are not marked INFLIGHT. The AW lock tracks that the epoch is active.

INFLIGHT means temporary exclusion from reads during an epoch, not that some RPC is outstanding. It stays set between writes until the epoch closes, when mirrors that were synchronized at entry are expected to be consistent again. IWM clients exclude INFLIGHT mirrors explicitly, and patched 2.18/master and 2.15 clients exclude them as unknown mirror-state flags. Unpatched clients ignore unknown flags (layout verification checks only sizes and offsets; selection tests only STALE, PREF_RD, INIT and PARITY; lov_ea.c:441-470, lov_object.c:689, 721 at 47638add78), which is why the MDT refuses their opens (§2.4.6).

STALE means the mirror is out of sync by an unknown amount; its contents must not be used until a full resync. It is the same flag delayed-write FLR uses. Participants use INFLIGHT during normal writing, and become STALE only through errors or client loss at close. Pre-existing STALE survives entry and close until resync. With the optional primary-only write setting, every non-primary mirror, IMMEDIATE ones included, becomes STALE before the write is admitted.

Clean non-immediate mirrors. IMMEDIATE describes write participation, not synchronization: a non-immediate mirror may be clean after a resync or after removal from the immediate set. Before admitting any modifying IO that omits such a mirror, including IO by IWM clients on the participants, the MDS marks its affected components STALE under ordinary FLR rules (§4.2).

Participants’ flags are settled at epoch close (§2.2.4), which is separate from staling omitted non-immediate components before IO. STALE mirrors are visible to lamigo through changelogs (§2.4.5).

Write/Update Error from Client

Clients associate errors from every modifying operation with the epoch. Retryable secondary events follow the mount’s secondary policy; one that recovers does not stale a mirror. A terminal error at any stage (submission, transport or recovery, the RPC, or commit/sync), or administrative abandonment, is recorded as a mirror failure. The client then resolves its remaining IO and releases AW without waiting for the retention timer, reporting the error and triggering closure (§2.3.3.1).

Client Eviction from OST

Eviction from an OST can invalidate that mirror’s completeness and commit guarantees at any point, not only at commit. The client reports the mirror failed in its AW cancellation, after safely resolving its outstanding IO, and the mirror becomes STALE at close, for resync by lamigo or other tools. Regaining the connection later does not make an eviction-damaged mirror clean, whatever the policy.

Client Eviction from MDT

If a client holding AW is evicted from the MDT, its outcomes are unknown. The ordered close drains and flushes the responsive writers, not the evicted one. The outcome follows the lost-participant rule (§2.2.4): resolve a known primary error first, then every mirror other than the resulting primary becomes STALE in full, regardless of reported error detail, with the primary kept as a best-effort copy that may lack some of the lost client’s writes. Late writes from the evicted client are fenced by the FLR era-ending version push, not an extent-lock flush; sequencing is open in lustre-design-docs-iwm.6.1.

Client Loss

A client that crashes or otherwise disappears is evicted from both the MDT and the OSTs; the MDT eviction governs, so this is handled exactly as above.

MDS Failover

On MDS restart, durable tracking identifies open epochs, participants and reported errors; after ordinary lock replay and reconciliation, affected epochs are rolled up with the ordinary close rules, and any participant that did not recover is a lost participant on its own files only. See Recovery After MDS Failover.

Primary Mirror Write Failure

If writes to the primary fail, clients still complete the other mirrors’ writes and report as usual, and the MDS closes the epoch normally (§2.2.4). It selects a replacement primary from the eligible participants with no known error (from collected reports and durable error records, by the usual policy) and marks the failed old primary STALE. This happens before any lost-participant rule, which is then evaluated against the new primary. A clean non-immediate copy, such as demoted flash, is not eligible without conditional promotion. The outcome is part of the atomic closing transaction.

When no replacement is eligible (every candidate failed or was already STALE): report the errors through the usual logging and IO error paths, keep the epoch’s existing primary as the sole read-eligible copy, clear INFLIGHT and mark every other mirror STALE; a previously STALE secondary is never rehabilitated. This is best effort: it does not mean the primary is complete or that the failed writes succeeded, and normal access may still hit IO errors. Resync uses the primary’s current contents to restore redundancy but cannot recover missing updates. No new degraded flag or administrator source selection is involved. The rationale is to match the existing best-effort behaviour for ordinary write errors without IWM. The same outcome applies in live closure and recovery.

Replacing Mirrors/Permanent Failure

When a mirror is left STALE after a write failure, lamigo is notified through changelogs, which it already watches for STALE mirrors as part of Hot Pools; no new notification path is needed. Changelog overhead is modest (5-10% for metadata-intensive workloads, less for IO-intensive ones). If resync keeps failing, for example because an OST has failed permanently, lamigo can add a replacement mirror (lfs mirror extend) and the failed mirror can be removed later. No special commands are needed.

These operations quiesce writers through the administrative LAYOUT EX → AW EX path (§2.2.7). Resync takes a lease (LL_LEASE_RESYNC) and its resync request takes LAYOUT EX on the MDT; AW EX then closes any open epoch, draining writers under their existing protection. If that epoch, or an earlier one, lost a participant, resync confirms the era-ending version fence on the source mirror’s objects before copying (§2.2.4). Resync instantiates the components it needs; if that keeps failing, it fails and leaves the mirror STALE.

New writers stall on layout acquisition (and AW where applicable) until the operation completes; that is not a write failure. Adding a mirror and resyncing it are separate commands, so there are two quiesce windows, acceptable for an infrequent recovery operation.

Conflicting Operations During Write Epochs (Resync, Old Clients)

Compatibility policy:

  1. Clients we can modify (2.18/master and 2.15) treat an unknown mirror-state flag as making the mirror unusable, as if STALE, so a client that does not understand INFLIGHT skips the mirror rather than reading an inconsistent copy. They advertise this with a new connect capability, distinct from OBD_CONNECT2_FLR_IMMED_MIRROR (full IWM support; already reserved at lustre_idl.h:855). This is a local selection rule: the client never writes STALE into the layout or clears unknown flags. Full IWM clients advertise the capability too.
  2. Clients without the capability cannot open a file that has any IMMEDIATE mirror, whatever the file’s state (RDONLY, WRITE_PENDING, with or without AW holders). Existing pre-FLR exclusion is unchanged.

Known flags. A skip-unknown client must treat IMMEDIATE as known membership metadata (which does not imply AW support), or the rule would also exclude the primary. The mask covers mirror-state flags only, not arbitrary component flags or layout formats. INFLIGHT may stay unknown and be skipped; a client that recognizes it must exclude it the same way. Unknown state may not be ignored in read selection, size and attribute queries, or any fallback path that would silently pick an excluded mirror.

Non-open access by clients without the capability. Data access needs an open, so the open denial covers all reads and writes. Metadata paths without an open (stat/ls -l via glimpse, getxattr, lfs getstripe) stay allowed. During an epoch such a client may glimpse an INFLIGHT secondary or fold it into its size (the maximum over non-STALE components of all mirrors, lov_attr_get_composite) and briefly report a size slightly behind or ahead of the primary. That is transient, limited to the epoch, exposes no data, and is accepted. Withholding the layout (which would break stat) and per-client layout rewriting were rejected; old clients are not a design target.

Capable clients without full IWM support. They read the primary and never force epoch closure just to read. Writes are denied by default. An optional setting allows primary-only writes through the existing FLR write-intent sequence: any open epoch is closed with LAYOUT EX → AW EX, the MDT changes the layout and marks every non-primary mirror (IMMEDIATE ones included) STALE, then the primary-only write proceeds under ordinary FLR layout, extent-lock and resync serialization. That deliberately gives up immediate redundancy until resync; it is not participation in an IWM epoch, and a later clean IWM epoch never clears that STALE. The setting never admits clients without the capability.

Promoting IMMEDIATE on an open file is a migration-style protected operation, not a raw flag change: it reuses existing migration quiescence, layout invalidation and revalidation, and serialized publication; closes any open epoch with LAYOUT EX → AW EX; and keeps exclusion through the conditional clean-copy and membership checks (§4.2) until publication. Promotion never clears STALE, and needs no data copy or temporary inode when the intended copies are already clean. Ordinary migration contention, retry and failure handling apply.

Existing descriptors are not grandfathered. Migration preserves file identity and open handles, so applications are not forced to reopen. Supported holders continue through normal layout refresh and AW admission. Holders without the capability must not obtain or use the promoted layout through an old descriptor, cached layout, PW or mmap authority, or reconnect and replay: the capability gate is enforced on layout return and refresh and on IO admission, not only at open. This denies unsupported access; it does not destroy descriptors or evict clients.

Migration precedents at 47638add78: blocking migration (lustre/utils/lfs.c:1085) takes a group lock and swaps layouts after a data-version check; nonblocking migration (lustre/utils/lfs.c:1608) uses a lease and an atomic swap at close; the MDT swap handler (lustre/mdt/mdt_handler.c:1839) takes LAYOUT/XATTR EX. These are precedents, not proof: stock migration provides neither the capability gate nor the AW drain, the blocking path takes its group lock before MDT LAYOUT EX (not automatically compatible with the AW order), and the swap handler checks layout support only for the requester.

Resync closes any open epoch first, whatever the client’s capability, then follows normal resync serialization (§2.4.5); an ordinary read needs neither.

Deferred: state-conditional access for clients without the capability. Allowing them to open files only between epochs would not make their activity vanish when the next epoch opens: they could keep descriptors, layouts, cached PW or mmap authority, dirty data, RPCs and retries without any AW lock, and revoking a soft layout is not a writeback or durability barrier. It would need a server-driven drain and fence mechanism that has not been designed.

Open items (lustre-design-docs-od5.40, lustre-design-docs-iwm.1, checklist P4): the capability’s wire bit and the mirror-state mask; enforcement points and denial errors across open, create-with-layout, layout return and refresh, reconnect and replay, serialized with layout and membership changes; invalidation of reads already in flight at entry; the primary-only write setting’s name, scope and enforcement; for promotion, the group/lease/AW placement, draining legacy PW, mmap, dirty data and IO (including holders and older clients without layout-invalidation support), and making sure migration exclusion never blocks admitted IO or the cancellation flush; partial-component mirror handling.

Future Enhancements and Alternatives

Cancellation LVB Format and Future Stripe Detail

The v1 cancellation report carries mirror-level errors only, but uses an extensible structure from the start, so that per-OST-object detail can be added later for finer repair and immediate EC without changing the mandatory summary or the conservative whole-mirror fallback.

Message structure (v1):

  • A versioned header with explicit header and total-message lengths, enough to find the mandatory summary and any extensions, plus the epoch ID (the opening layout generation; §2.2.1).
  • A mandatory count followed by that many failed mirror IDs (16-bit, as in lcme_id). A count of zero is a clean report. Membership changes need an epoch boundary, so IDs keep their meaning for the whole epoch.
  • An optional sequence of typed, length-delimited detail records. V1 sends none, but the framing exists so a receiver can skip unknown optional records within a supported version and still process the summary. An unsupported top-level version is not covered by that skipping rule.

The receiver validates lengths, the count, the epoch ID, and that each ID is a participant of that epoch, then takes the union of all valid reports; any reported mirror becomes STALE as a whole. A missing or invalid report is never treated as a clean one; it gets conservative handling. Field widths, alignment and message limits are specified with the RPC implementation. The current LDLM_CANCEL request carries no LVB; adding the field, associating reports with the right lock in batched and early cancellations, and packing and validation are v1 protocol work.

Sizing. The summary is a count plus 2 bytes per failed mirror, bounded by the mirror count (LUSTRE_MIRROR_COUNT_DEF 16, LUSTRE_MIRROR_COUNT_MAX 256); a clean report carries only the count.

Future per-stripe detail. Per-stripe error recording is TBD, including how failures are collected, stored durably, aggregated across layout versions, replayed and consumed; v1 durable tracking records failed mirrors only. A future detail record would identify the mirror, the layout mapping that defines its index space, the number of slots, and an error bitmap, under these rules:

  • One flat index space per mirror. Concatenate the stripe lists of the mirror’s components in order and number the OST-object slots from zero: with stripe counts n0, n1, ..., local stripe s of component k has index n0 + ... + n(k-1) + s (two components of 2 and 3 stripes occupy 0–1 and 2–4). Components without OST objects contribute nothing. This counts object slots, not file bytes.
  • Layout changes. Indices are interpreted against the layout under which the IO was issued. Since an epoch can span layout changes, client and MDS must keep or translate that mapping before combining detail; without a precise translation, fall back to the whole-mirror summary. This does not force epoch closure for component instantiation.
  • Merging. A detail-aware receiver may refine repair only with complete, valid detail from every relevant report; missing or unsupported detail never makes a reported mirror look clean.
  • Size. A bitmap for N slots is ceil(N / 8) bytes before record overhead (25, 63 and 250 bytes for 200, 500 and 2000 slots). LOV_MAX_STRIPE_COUNT (2000) limits one plain component, not a mirror’s total, so 16 × 2000 is not a realizable worst case; the RPC implementation must derive and enforce a real bound, falling back to the mirror summary when detail does not fit.

Per-extent reporting (byte ranges of failed writes within a stripe) is a further level whose size grows with the number of disjoint writes; it is not part of v1 or the per-object extension.

Write Ordering Alternatives

v1 uses the primary mirror as locking leader, requiring client-side LDLM locks for all IO including DIO — a performance regression for shared-file DIO workloads that normally operate locklessly. Possible future approaches to restore lockless DIO:

Chained RPC Checksums

One option is to have OSTs maintain a chained checksum of committed writes. In the simplest form, this requires identical RPCs to all mirrors, which requires mirrors to have identical layout geometry — a constraint the v1 BRW page fan-out approach was specifically designed to avoid (see IO Duplication). However, the chained checksum concept could be extended to work with heterogeneous layouts by checksumming at the logical file offset level rather than the RPC level — the OST would chain checksums of (offset, length, data_checksum) tuples as they commit, producing a layout-independent ordering fingerprint. This would require the MDS to compare per-stripe chains after mapping them back to file-level extents, adding complexity but removing the identical-layout restriction. When a write epoch opens, the OST would be informed the stripe object is part of an immediate mirror file. It would take the write checksum from each committed write and chain them together as they are committed (write commits can be ordered by their journal transaction number, even if they are occurring in parallel). The result is a single checksum value which encodes both the writes and their ordering. Non-write operations can be included by checksumming their arguments (e.g., the byte range and fallocate op type for fallocate).

The write primary mirror would be the correct ordering, to which the others are compared. If ordering on a secondary mirror disagrees with the primary, that mirror would be left STALE (not un-staled), requiring a full data resync.

Open questions for this approach include: whether to apply this only to overlapping operations (which requires tracking the extent of all data-modifying operations through a write epoch, but avoids false positives from non-overlapping writes), and recovery scenarios when an OST fails mid-epoch and the checksum chain is incomplete.

A related but heavier-weight idea is having OSTs maintain full write commit vectors (ordered lists of committed write extents) and comparing them across mirrors at epoch close. This subsumes chained checksums but requires significantly more per-OST state and MDS-side comparison logic. # Policies and Open Items {#open-problems}

Operation Completion Consistency Model

Application return, RPC completion, durable commit, AW release and epoch closure are distinct events. The v1 contracts:

Boundary V1 contract
Ordinary buffered write() May return once data is in the client cache, before any mirror RPC. Normal asynchronous aggregation; no stable-storage promise.
Buffered dirtying / mmap write permission Each modifying IO takes AW at admission, after layout validation and before PW use or dirtying, not at writeback. Dirty data stays the epoch’s responsibility across syscall return and layout refresh.
Mirror RPC / writeback completion Every mirror attempt has completed or been safely resolved before source buffers and ordering protection are released. A reply is not proof of durable commit.
DIO completion Stable storage without synchronous flags: every participant’s updates committed or recorded as failed, outstanding activity resolved. The primary determines the result.
fsync(), fdatasync(), O_SYNC, O_DSYNC For the operation’s data/metadata scope, committed-or-recorded-failed outcomes with outstanding activity resolved. Primary success is success despite secondary failure.
AW release Drain admitted IO (including IO that has not dirtied yet), flush the whole file, and synchronously confirm durability on every mirror reported successful, with failures resolved and reported. No opt-out; never with dirty data remaining.

Explicit durability requests keep the usual distinctions between full and data-integrity synchronization, and between file flushes and synchronous writes. Primary writeback and commit errors, relevant earlier buffered-write errors and required metadata errors are reported normally. A secondary-only failure is never surfaced on a later fsync() when it would be invisible for the write itself. V1 has no “durable redundancy” success requirement; an explicitly requested stricter policy could be added later, consistently across APIs.

No global-close barrier. Completing an operation never requires other clients to release AW, the epoch to close, or the final layout to be published; a durability request does not take AW EX or wait for unrelated writers. AW may stay held for later writes. Local commit and error tracking survives operation completion until reported, and durable tracking with conservative recovery covers an outcome lost before reporting. A successful fsync() does not make secondaries readable; only epoch close does.

Combining Immediate and Non-Immediate Mirrors

Real deployments combine immediate and non-immediate mirrors on one file. For example, DDN’s Hot Pools places latent mirrors on HDD that are filled by resync (lamigo) once a file ages out of flash: a file might have two flash mirrors written immediately for availability and two HDD mirrors synced lazily for capacity.

The IMMEDIATE flag alone determines participation; no new “immediacy group” or file-level layout state is needed, and RDONLY → WRITE_PENDING → SYNC_PENDING stay as they are. Non-immediate mirrors are not necessarily STALE: resync can leave HDD clean before promotion, and demotion can leave flash clean but no longer immediate. Before any modifying IO that omits such a copy, its affected components are marked STALE (§2.4.1), which is what makes conditional promotion safe: a copy that missed a write never still appears clean. Adding and removing IMMEDIATE are conditional layout operations at epoch boundaries that change who participates in later epochs, never the participants of a running one.

Tiering lifecycle (e.g. flash to HDD):

  1. The file starts with two immediate flash mirrors.
  2. HDD mirrors are added without IMMEDIATE; they need resync.
  3. When the file ages out:
    1. Resync HDD with ordinary resync. That makes the copies clean at completion, not afterwards.
    2. Conditionally promote HDD. Close any open epoch and resolve its outcomes; then, while excluding modifying IO and competing layout changes, check that the intended HDD copies still exist and are clean over the required extents, and set IMMEDIATE in the same serialized transition. A userspace check followed by an unconditional flag update is not enough. If a write staled the copies meanwhile, promotion fails and the caller resyncs and retries. Promotion never clears STALE.
    3. Keep both tiers immediate during the overlap. Later epochs write all healthy flash and HDD participants, so there is never a window without immediate copies; failures can still stale a participant.
    4. Conditionally demote flash. Close the overlap epoch and process its outcomes. Clear IMMEDIATE on the flash mirrors only if the retained immediate HDD copies still provide the clean coverage and redundancy required; an IMMEDIATE flag alone is not evidence of a healthy copy. Otherwise leave flash immediate. If flash was the primary, an eligible HDD primary is selected before the next epoch.
    5. Remove flash copies separately, after revalidating that the retained copies make removal safe; an earlier successful demotion does not authorize deletion regardless of later failures.

Serialization. Promotion and demotion take LAYOUT EX, then AW EX: invalidate old layout views, drain and flush writers, check conditions, and publish the new membership before admitting writes under it. AW EX alone would not suffice if clients could resume with an old fan-out. Clients that never held AW are handled by the capability gate and the migration-style promotion of open files (§2.4.6).

Progress. Copying, promotion, demotion and removal are separate operations, and stopping between them leaves a valid state (HDD copied but not promoted, both tiers immediate, or flash demoted but not removed). Continuous writes can re-stale HDD between resync and promotion indefinitely, so v1 gives no progress guarantee on a continuously written file; promotion needs a write-quiet interval. A future resync-and-promote operation could hold serialization through promotion (it would still need writers to pause).

Interaction with resync. Resync of a non-immediate mirror takes LAYOUT EX, then AW EX to close any open epoch, and keeps normal resync serialization through the copy; afterwards writers may resume and re-stale the copy before promotion, which is why promotion is conditional.

Administrative redundancy guard. A demotion or removal that would leave fewer than two healthy IMMEDIATE copies requires an explicit force override. Removing or demoting a copy that is already STALE or otherwise not counted does not reduce healthy redundancy and needs no force. The count is taken after the ordered drain, rollup and primary processing, in the same serialized transition as the membership change, counting clean, valid coverage rather than IMMEDIATE flags. Force overrides only this guard: never locking, drain, error resolution, retained-copy validity or safe primary selection, and it cannot clear STALE. Demoting the last IMMEDIATE participant still requires a valid retained copy and resolution of the old AW obligations. The guard does not imply a two-copy minimum at creation and does not prevent failure-induced degradation.

Open items (lustre-design-docs-od5.44, lustre-design-docs-od5.40): conditional promotion and demotion in the layout-management interface and tiering controller, with retries; the force override’s API and CLI encoding, denial errors and where it is enforced; tests of post-drain counts, cleanup of degraded files and force without weakening retained-copy validity.

Heterogeneous Compression Across Mirrors

Heterogeneous compression across immediate mirrors needs no special protocol: each secondary compresses with its own settings when its RPCs are rebuilt, provided client-side chunk alignment uses the largest chunk size among participants. See Compression and Encryption Across Mirrors.

Open Items Summary

Everything still open, by area; details are in the sections cited. The shared protocol checklist (iwm_discussion_checklist.md, P1–P8, A7, A9) tracks the verification work.

  • Questions needing a decision
    • Andreas Dilger: recovery rollups as a final recovery phase or as ordinary post-recovery operations; recovery hook, cancellation availability and service-resumption timing (§2.2.5).
    • Patrick: failure-only close residue: how recovery treats a committed closing layout whose bookkeeping cleanup failed (§2.2.5, lustre-design-docs-od5.61).
    • Whether close reuses SYNC_PENDING internally or an equivalent era-ending step (§2.2.4).
  • Client admission and AW lifetime (P1–P3; lustre-design-docs-od5.8, lustre-design-docs-od5.36): admission hook placement and coverage of every path (§2.3); IO reference boundaries and the final-flush barrier; voluntary-release gate; mmap fault/PTE handoff and PCC mkwrite; cached PW/AW lifetimes; retention timer value and controls; local lock dependencies in pruning and fan-out.
  • Compatibility (P4; lustre-design-docs-iwm.1, lustre-design-docs-od5.40): capability bit and mirror-state mask; enforcement points and errors; old-read invalidation at entry; primary-only write setting; migration-style promotion integration.
  • Epoch entry and grants (P5–P6; lustre-design-docs-iwm.3.2): reply representation for the two grants; ordered handoff serialization; lost and partial replies, retry and replay.
  • Release, close and recovery (P7–P8; lustre-design-docs-od5.21, lustre-design-docs-od5.29, lustre-design-docs-od5.30, lustre-design-docs-od5.61): durable error-bearing CANCEL delivery; lock progress during recovery; storage schema, identity and shard design; failure-safe execution order; era-ending fence sequencing (lustre-design-docs-iwm.6.1); atomic close and dependency ordering verified on ldiskfs and ZFS; handoff authority after closure.
  • Secondary failure policies (lustre-design-docs-od5.33): timings, controls, sampling; abandonment interface, distribution and reconciliation; dead-OST lifetime and reactivation.
  • Other operations and durability (A7, A9): truncate/punch/fallocate ordering and error contract; DIO flag preservation through fan-out; both-backend tests.
  • Implementation follow-ups with design impact: write-duplication rework of Gerrit 63244 (lustre-design-docs-iwm.4.1); STATE_ONLY_CHANGE with per-mirror prune (lustre-design-docs-iwm.3.3); all-participant component instantiation (lustre-design-docs-od5.49.12); DoM exclusion and admission-path audit (lustre-design-docs-od5.49.14); secondary space accounting (lustre-design-docs-od5.49.8); tiering controls and the redundancy guard (lustre-design-docs-od5.44).
  • Existing FLR bug found by this design: resync can mark mirrors in sync after a stale writer’s late write (lustre-design-docs-od5.49.31; §2.2.4).
  • TBD by design: per-stripe error recording and repair (§3.1); restoring lockless DIO (§3.2); state-conditional access for clients without the capability (§2.4.6).

Decision Log

Decisions recorded with their dates. The sections above state the current rules; this log records when each was settled and what it replaced. Beads are in the lustre-design-docs tracker.

Date Decision Where Replaces
2026-09-08 Application completion contracts A1–A6 (buffered return, dirty-data ownership, RPC completion, DIO durability, explicit sync with degraded success, no global-close barrier) §4.1 —
2026-10-02 Interrupted epoch opening uses ordinary recovery: after replay and reconciliation, listed epochs with no recovered holders close through the ordinary close §2.2.5 A separate interrupted-opening outcome or admission acknowledgement
2026-10-02 The first AW request opens the epoch (primary selection, WRITE_PENDING, INFLIGHT, durable active-epoch record); later requests join §1.2, §2.4.1 Epoch entry driven by a later write intent
2026-10-02 Strict LAYOUT → ACTIVE_WRITERS ordering on client and server; soft client layout hold; component/SEL changes are layout-only §1.2, §2.2.7 AW-first ordinary close
2026-10-02 Capability-based compatibility: patched 2.18/master and 2.15 clients skip unknown mirror-state flags and advertise it; others cannot open IMMEDIATE files in any epoch state §1.2, §2.4.6 Blanket epoch closure on every non-IWM layout request; state-conditional access (deferred)
2026-10-02 Capability-bearing non-IWM clients: writes denied by default; optional setting allows primary-only writes that stale every non-primary mirror via ordinary FLR intents §1.2, §2.4.6 —
2026-10-02 Epoch close uses LAYOUT EX → AW EX; AW cancellation drains old IO and flushes the whole file without taking new locks §2.2.4, §2.2.6 AW-first close; layout reacquisition during drain
2026-10-02 Entry reply returns the updated layout and a replacement LAYOUT grant with the independent AW grant; reuse FLR’s layout handoff and client refresh §2.2.7 Two round trips; LDLM lock conversion
2026-10-05 Durable participation recorded for every client’s AW acquisition, including joins §2.2.5 FID-only active tracking with filesystem-wide degradation on client loss
2026-10-05 Mirror-error records are distinct from participation, carry no reporter identity, and are consumed only by the atomic close §2.2.3, §2.2.5 Errors annotated on, or living with, participation
2026-10-05 Recovery order: load tracking, finish replay and reconciliation, then roll up; primary error resolved before the lost-participant fallback §2.2.5 —
2026-10-05 Last voluntary release triggers rollup; any error-bearing release triggers it immediately; raced joins are drained; no “rollup pending” gate §2.2.4 —
2026-10-05 Error-bearing cancellation: one MDT transaction records errors and retires participation, durable before ACK §2.2.3 —
2026-10-05 Close: one MDT transaction publishes the closing layout, consumes handled errors and removes the active-epoch record; no extra synchronous close commit §2.2.4 Layout publication followed by separate cleanup
2026-10-05 Canonical client admission: LAYOUT, AW, revalidate; on layout loss drop the IO’s AW reference before waiting §2.2.7 —
2026-10-06 Three secondary failure policies (wait indefinitely, slow-fail, fast-fail) differing only in timing; administrative abandonment is separate §1.2, §2.3.3.1 Two-mode fast/slow model
2026-10-06 Reuse unmap_mapping_range() for voluntary/idle AW mapping revocation §2.2.2, §2.2.7.3 A new mapping-revocation scheme
2026-10-06 Secondary policy per client mount, default finite slow-fail; changes affect new IO only §2.3.3.1 Per-file policy
2026-10-06 Administrative abandonment is an OST-targeted client action, local or triggered cluster-wide when an OST is declared dead §2.3.3.1 Per-file mirror abandonment
2026-10-06 Promoting IMMEDIATE on an open file is a migration-style protected operation; old descriptors are not grandfathered §2.4.6, §4.2 Refuse promotion until incapable holders close
2026-10-07 No eligible replacement: keep the existing primary as the sole best-effort copy §2.4.4, §2.2.5 All mirrors STALE
2026-10-07 Unmatched replayed AW is rejected and handled conservatively, not an LBUG (review F-13) §2.2.5 LBUG
2026-10-07 Logically separate record kinds; in-memory client lookup rebuilt on restart; recovery rollups by bounded parallel workers with no progress journal §2.2.5 —
2026-10-07 Storage mechanics deferred to detailed design; dedicated DT indexes are the preferred candidate §2.2.5 —
2026-10-07 lfs mirror create --immediate marks all created mirrors IMMEDIATE §1.2 —
2026-10-07 V1 scope: DoM excluded; NFS, PCC and HSM supported under normal admission and drain §1.2 —
2026-10-07 Hybrid IO takes the DIO client lock; group locks allowed with their existing no-ordering contract (review F-07) §1.2 —
2026-10-07 Synchronous durability at every AW release; no opt-out §2.2.2 iwm_sync_on_epoch_close opt-out
2026-10-07 AW cache retention: ordinary LDLM semantics plus a separate shorter timer (about 60 s) §2.2.2 Dedicated 1–5 s idle timer
2026-10-07 Drain then FLR era-ending version fence for lost participants (no MDT extent-lock flush) §2.2.4 OST extent-lock flush as the fence
2026-10-07 Fence only lost-participant closes; close does not wait for delivery; copy-to-clean operations confirm the fence first (review F-03) §2.2.4 —
2026-10-07 Epoch ID is the opening layout generation (review F-04) §2.2.1 —
2026-10-07 Cancellation report carries failed mirror IDs, not a bitmap (review F-05) §2.2.3, §3.1 Counted mirror bitmap
2026-10-07 Clean cancellations commit lazily by default; MDT commit cost model (review F-15) §2.2.3 Optimization to evaluate
2026-10-07 No maximum epoch length (review F-16) §2.2.2 —
2026-10-07 Release barrier built on per-file transno tracking against last_committed; DIO already durable at reply (review F-25) §2.2.2 —
2026-10-07 Component instantiation covers every participant; a secondary instantiation failure is a secondary error §2.2.7.2 Primary-only instantiation
2026-10-07 STATE_ONLY_CHANGE prunes sub-objects of mirrors that lose read eligibility (review F-01) §2.2.7.3 Keeping all cached pages
2026-10-07 Secondary ENOSPC/EDQUOT handled when attempted; no advance reservation in v1 §2.3.3.1 —
2026-10-07 FIEMAP reports the primary only §2.3.2.1 —
2026-10-07 Primary RPC slot freed at its reply; only completion waits for secondaries (review F-20) §2.3.1.1 Prototype’s slot hold
2026-10-07 Ordering invariant: no primary extent lock release while a covering secondary attempt is unresolved (review F-06) §2.3.1.1 —
2026-10-07 Heterogeneous compression via per-mirror rebuild with chunk alignment to the largest participant chunk; bounce pages reused when settings match (review F-09) §2.3.1.5 “OST handles RMW”; bounce reuse deferred
2026-10-07 Gerrit 63244 cited as a feasibility demonstration with a deltas table (review F-21) §2.3.1 “Proof of concept”
2026-10-07 Clients without the capability may still stat IWM files; transient size inaccuracy accepted (review F-11) §2.4.6 —
2026-10-07 Administrative redundancy guard: demotion or removal leaving fewer than two healthy IMMEDIATE copies needs force §4.2 Deferred to end of review