Skip to content

Add object-store fencing token for remote store primary term validation - #22774

Open
Bukhtawar wants to merge 11 commits into
opensearch-project:mainfrom
Bukhtawar:remote-store-fence
Open

Add object-store fencing token for remote store primary term validation#22774
Bukhtawar wants to merge 11 commits into
opensearch-project:mainfrom
Bukhtawar:remote-store-fence

Conversation

@Bukhtawar

@Bukhtawar Bukhtawar commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Description

Remote store + segment replication fences a stale primary through replica-based primary term validation, which needs at least one replica as witness. Zero-replica setups therefore cannot safely recover a shard onto a new node after a node loss: nothing stops two copies writing to the same remote store paths.

This PR makes the object store itself the witness: a per-term fence object updated only by conditional writes. A copy that loses the compare-and-swap is fenced and can never acknowledge a write. This is the multi-writer safety property the zero-replica auto-restore work in #22768 needs (the allocation trigger is not in this PR). For replicated request-durability indices it is a trade — replica witness → object store witness (NO_REPLICATION below); for zero-replica indices it is a win outright.

The protocol

Notation: inv(t) = invertLong(t) — written inv(5), inv(6) rather than the raw inverted longs.

  1. One object per primary term: fence__inv(term), so a prefix listing returns the highest term first — that name ordering is how the object store orders cluster-manager grants it cannot interpret, with no cluster-manager I/O. Content (v1|indexUUID|shardId|term|allocationId|nodeId|seq, 68 bytes) records identity; the store's ETag is the token. seq makes every write's bytes unique, so a content-digest ETag can never repeat (no ABA).
  2. The chain gates the ack. Every translog upload must win a CAS (If-Match <etag>) on its term's object, run in parallel with the metadata upload and joined before acknowledging. Losing the CAS is fatal for the copy: the shard fails instead of retrying. Only a proven precondition failure counts as a lost CAS; ambiguity (transport errors, 409 ConditionalRequestConflict) stays retryable, and a lost response is resolved by re-reading the object and matching identity + seq.
  3. Higher-term takeover is deterministic, not a race. List (refuse if a strictly higher term owns a path) → create own key with If-None-Match: * (uncontested — a lower-term incumbent never writes that key) → delete every lower-term object (the act that fences the incumbent) → re-list and verify. A writer's only destructive act is deleting strictly below its own term, so it can never touch a higher-term writer's path.
  4. Seal before restore. A copy taking over — recovery or replica-to-primary promotion — claims the chain before reading its translog restore point, closing the acked-write-loss window: the partitioned previous primary's token dies before the restore point is read. The fence is claimed twice per takeover: the seal (a throwaway instance) and then the translog instance's re-adoption on first upload — the re-adoption requires the object to still record this copy's allocation id, so an equal-term twin that legitimately claimed the chain in between is respected rather than raced (FenceTakeover.tla: unguarded this loses acked writes in 17 states; guarded, all invariants hold).
  5. Same-term relocation handoff is authorized by recorded ownership. Source and target share a key, so the term cannot arbitrate. The drained source writes the target's allocation id as owner (uncontested) and retains the resulting ETag; on abort it reverts with that ETag — success proves the target never wrote (resume), failure proves it did (stand down). The target adopts only a chain that records it.
  6. The segment flow is gated by reads, not CAS. Publishing segment metadata and collecting stale segments both mutate state shared with the legitimate owner, so both check "has a strictly higher term taken the fence?" on separate gates with opposite failure directions: publication fails open (worst case one orphan), collection fails closed (a wrongly permitted delete is unrecoverable). Separate checks mean one unreadable-fence event can never relax both — the combination FenceSegmentFlow.tla proves unsafe.

Gating

  • cluster.remote_store.fencing.enabled — dynamic cluster setting (default false), baked at index creation into
  • index.remote_store.fencing.enabledfinal index setting (toggling a live index's write witness would leave a window checked against neither fence nor replicas). Explicit per-index value wins. A repository without store-enforced conditional writes fails shard startup fast rather than running unfenced.

1. Steady state — every acknowledgement is a successful conditional PUT

sequenceDiagram
    autonumber
    participant C as Client
    participant A as Primary A (term 5)
    participant S3 as Object store
    Note over A: holds ETag E0 for fence__inv(5)
    C->>A: index op
    A->>S3: PUT translog data
    par immutable metadata upload
        A->>S3: PUT metadata__…
    and fence CAS (off the latency path)
        A->>S3: PUT fence__inv(5), If-Match E0, seq+1
        S3-->>A: 200, new ETag E1
    end
    A->>A: join both before acknowledging
    A-->>C: ack
Loading

2. Failover across terms — deletion is the fencing act

sequenceDiagram
    autonumber
    participant A as Primary A (term 5, partitioned)
    participant S3 as Object store
    participant B as Copy B (appointed term 6)
    Note over A,S3: fence__inv(5): owner allocA
    B->>S3: LIST fence__* → only inv(5), nothing higher → proceed
    B->>S3: PUT fence__inv(6), If-None-Match *
    Note over B: uncontested — a lower term never writes this key
    B->>S3: DELETE fence__inv(5)
    B->>S3: LIST again — claim stands
    A->>S3: PUT fence__inv(5), If-Match (stale ETag)
    S3-->>A: refused — key is gone
    Note over A: fenced before acknowledging — shard fails
    B->>S3: only now read the translog restore point
    Note over B: everything A ever acknowledged is already in it
Loading

3. Relocation handoff at a constant term — the ETag answers what the copies cannot ask each other

sequenceDiagram
    autonumber
    participant S as Source (term 5)
    participant S3 as Object store
    participant T as Target (term 5)
    S->>S: block ops, final sync, drain uploads
    S->>S3: PUT If-Match X → owner = allocT
    S3-->>S: 200, new ETag Y (retained)
    S->>T: hand off primary context
    S--xS: response lost → abort
    S->>S3: PUT If-Match Y → owner back to allocS (revert)
    alt 200 — nothing wrote since Y
        Note over S: target never took over → resume
        T->>S3: read fence → owner is allocS → stand down
    else refused — the target already wrote
        Note over S: handoff effectively completed → stand down
        T->>S3: continues acknowledging as owner
    end
Loading

4. The segment-flow gates

flowchart TD
    R["refresh → syncSegments()"] --> Q{"publish gate:<br/>higher term owns the fence?"}
    Q -- no --> RUN["proceed"]
    Q -- yes --> SKIP["skip, do NOT retry"]
    Q -- "unreadable — FAIL OPEN" --> RUN
    RUN --> D["segment DATA upload — ungated, purely additive"]
    RUN --> M["uploadMetadata — publishes the reference set"]
    RUN --> G{"collection gate (separate check):<br/>superseded or unreadable?"}
    G -- no --> GC["deleteStaleSegments"]
    G -- "yes — FAIL CLOSED" --> GS["skip — retried next cycle"]
Loading

Invariants

Documented by name in the RemoteStoreFence javadoc, referenced from the enforcing code:

Invariant Enforced
The chain gates the ack CAS joined before acknowledgement in TranslogTransferManager
One writer at a time store-enforced conditional writes; repositories without them are refused
Seal before restore runtime assertion at the read choke point — covers recovery, promotion, and all snapshot-restore paths
The term never regresses explicit term-floor checks; equal terms arbitrate by CAS
A higher term prevails, deterministically every takeover step is uncontested or unconditional — never decided by winning a race
Seq strictly increases runtime assertion in the CAS
Fenced is terminal explicit terminal state — a fenced instance never recovers a token, cannot claim a term it was never granted; only a new incarnation re-seals
One object per term, keyed by index UUID shard identity validated on every read; new-UUID restores and resize targets get fresh paths
Owner is advisory across terms, authorizing within one cross-term the ETag chain arbitrates; within a term recorded ownership authorizes (handoff, re-adoption)
No cluster-manager synchronization only term monotonicity and single-active-primary are borrowed from cluster coordination

Changes

  • Blob store API: BlobContainer gains isConditionalWriteSupported(), readBlobWithVersion(), writeBlobConditionally() (+VersionedBlob, BlobVersionConflictException). S3BlobContainer implements them over If-None-Match: * / If-Match; only 412, or 409 PreconditionFailed, counts as a lost CAS — everything ambiguous stays retryable. FsBlobContainer emulates CAS JVM-locally (lock striping + random tokens, not content digests) and reports unsupported, so fencing refuses it; only the test-only ReloadableFsRepository opts in.
  • Fence: RemoteStoreFence owns the per-term objects, the claim/sweep/verify sequence, same-term arbitration, ownership transfer/revert, and lost-response adoption by identity.
  • Write path: fence CAS dispatched concurrently with the metadata upload in TranslogTransferManager; fenced upload is tragic. With fencing + request durability, TransportShardBulkAction returns NO_REPLICATION (replicas leave the fencing path); async keeps the per-op fanout.
  • Recovery/promotion: seal hoisted above segment hydration and the restore-point read (IndexShard); promotion seals inside the term transition; peer-recovery/relocation targets exempt (the source still legitimately serves).
  • Segment flow: fail-open publish gate + fail-closed collection gates (RemoteStoreRefreshListener, both trimUnreferencedReaders).
  • Formal models: formal-models/remote-store-fence/ — four TLA+ modules checked by TLC over complete state graphs; opt-in (./gradlew :formal-models:modelCheck -PtlaToolsJar=…), deliberately not wired into check and downloading nothing.
Module Question States
RemoteStoreFence.tla seal ordering and acked-write loss 19,846
FenceTakeover.tla cross-term takeover incl. the two claims (seal, re-adoption): deterministic victory, tokenless-window safety 4,460,896
FenceHandoff.tla equal-term handoff, retries, concurrent takeover, target loss 591
FenceSegmentFlow.tla segment flow + GC gates, necessity measured by relaxation 20,008

Cost

One conditional PUT per translog sync (request) or sync interval (async), overlapping the metadata upload already on that path; one extra conditional PUT per seal (recovery/promotion); one GET per segment-sync/GC cycle on fenced indices.

Testing

RemoteStoreFencingIT (10 tests): chain advancement with 0–2 replicas, fence invisibility to metadata listings, seal-over on failover, incumbent fenced before ack, superseded copies failing recovery/promotion at the seal (teeth-verified: each fails with its seal disabled), resize targets, and settings gating. Unit coverage: RemoteStoreFenceTests (codec, identity, lost-response adoption), TranslogTransferManagerTests (CAS‖upload, fatal-vs-retryable), RemoteFsTranslogTests, RemoteStoreRefreshListenerTests (split gates), InternalTranslogManagerTests, S3BlobStoreContainerTests, FsBlobContainerTests.

Known limitations / follow-ups

  • Segment data files stay ungated (purely additive); segment-side verifyNoMultipleWriters unchanged — reviewer opinion welcome.
  • A fenced, never-acknowledged upload can leave an orphan metadata file (term-scoped, harmless); a seal marker for GC bookkeeping is a follow-up.
  • Observe-only rollout mode, the [RFC] Remote Store Fence: object-store-backed primary fencing to enable zero-replica auto-recovery #22768 auto-restore trigger, and a dedicated always-on fenced CI job (fenced mode currently randomizes across the 74 suites inheriting RemoteStoreBaseIntegTestCase; force with -Dtests.remote_store.fencing=true) are follow-ups.

Marking as draft while gathering feedback on the fence blob format and the immediate NO_REPLICATION switch (the RFC staged it behind Phase 0 — input welcome on which staging is right).

Review hardening (commits 5827e626e63b54)

A model-assisted review pass added six commits: the recorded-ownership re-adoption guard (protocol point 4, found by extending FenceTakeover.tla with the claimed-twice structure); S3 ConditionalRequestConflict reclassified retryable; the collection gate split fail-closed from the fail-open publish gate; lost-response transfer/revert resolved by identity with the handover inside IndexShard#relocated's reclaim scope; Fs token invalidation scoped with a trailing separator; dead isRelocationTarget plumbing removed (−55 lines). Uncovered IndexShard lines in codecov/patch are exercised end-to-end by RemoteStoreFencingIT, which patch coverage does not count.

Related Issues

Relates to #22768

Check List

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check here.

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit 6e63b54)

Here are some key observations to aid the review process:

🧪 PR contains tests
🔒 No security concerns identified
✅ No TODO sections
🔀 No multiple PR themes
⚡ Recommended focus areas for review

Possible Issue

FenceState.parse splits on "\\" + FIELD_SEPARATOR (i.e. \|), and String.split discards trailing empty strings by default. If any trailing field (e.g. nodeId or the last part) were empty, the token count check tokens.length != 7 would misclassify the blob, though the constructor rejects | in fields. More concretely: if seq field is ever the empty string at the end, split would drop it and the parse rejects a valid-length record. Since the writer always emits 7 non-empty tokens today this cannot fire, but it is a fragile invariant worth pinning with split(regex, -1).

static FenceState parse(byte[] content) throws IOException {
    String[] tokens = new String(content, StandardCharsets.UTF_8).split("\\" + FIELD_SEPARATOR);
    if (tokens.length != 7 || CODEC_VERSION.equals(tokens[0]) == false) {
        throw new IOException("Unrecognized fence blob content");
    }
    try {
        return new FenceState(
            tokens[1],
            Integer.parseInt(tokens[2]),
            Long.parseLong(tokens[3]),
            tokens[4],
            tokens[5],
            Long.parseLong(tokens[6])
        );
    } catch (NumberFormatException e) {
        throw new IOException("Unrecognized fence blob content", e);
    }
}
Correctness

CONDITIONAL_WRITE_VERSIONS and CONDITIONAL_WRITE_LOCKS are static (JVM-global). In delete() you invalidate map entries by prefix-matching the container path, but the fixed 64-stripe lock array means two unrelated containers can share a lock during CAS; more importantly, delete() mutates the version map without holding the per-blob lock, so a concurrent writeBlobConditionally on that path could observe a stale token immediately before the entry is removed and produce a spurious win/loss. In-process tests are unlikely to hit this, but it undermines the emulation's stated semantics.

            bytesDeleted.addAndGet(attrs.size());
            return FileVisitResult.CONTINUE;
        }
    });
    // Mirror the delete in the conditional-write version map, so that a blob recreated at the same path later is
    // a new version: any token issued before the delete must lose its CAS, as it would against a real object
    // store's ETag semantics. The trailing separator is load-bearing: without it, deleting the container at
    // /repo/idx-1 would also invalidate live tokens under a sibling like /repo/idx-10, spuriously fencing an
    // unrelated shard.
    final String containerPrefix = path.toAbsolutePath().toString() + path.getFileSystem().getSeparator();
    CONDITIONAL_WRITE_VERSIONS.keySet().removeIf(key -> key.startsWith(containerPrefix));
    return new DeleteResult(filesDeleted.get(), bytesDeleted.get());
}

@Override
public void deleteBlobsIgnoringIfNotExists(List<String> blobNames) throws IOException {
    IOUtils.rm(blobNames.stream().map(path::resolve).toArray(Path[]::new));
    // See delete(): deletion invalidates any previously issued version token for these paths.
    blobNames.forEach(blobName -> CONDITIONAL_WRITE_VERSIONS.remove(conditionalWriteKey(blobName)));
}
Possible Issue

validateAndAdvanceAsync executes on ThreadPool.Names.TRANSLOG_TRANSFER. If the executor rejects the task (shutdown or full queue with an abort policy), execute() throws synchronously from the caller's thread and the caller in TranslogTransferManager.transferSnapshot never counts down fenceLatch, causing the upload path to block until getClusterRemoteTranslogTransferTimeout() elapses and then fail as a timeout. Consider wrapping the submission so a rejection is delivered via listener.onFailure (and hence counts down the latch immediately).

public void validateAndAdvanceAsync(long primaryTerm, ActionListener<Void> listener) {
    threadPool.executor(ThreadPool.Names.TRANSLOG_TRANSFER).execute(() -> {
        try {
            validateAndAdvance(primaryTerm);
        } catch (Exception e) {
            listener.onFailure(e);
            return;
        }
        // Deliberately outside the catch. A listener that throws is a bug in the listener, not a fence failure, and
        // routing it to onFailure would both deliver two terminal callbacks and misreport a CAS that actually
        // succeeded as an upload failure. Let it reach the executor's uncaught handler instead.
        listener.onResponse(null);
    });
}
Possible Issue

trimUnreferencedReaders gates remote deletion on isRemoteStoreFenceSuperseded(). When fencing is disabled for this index the wrapper returns false (safe), but when fencing is enabled and the fence check throws IOException the code logs and returns, silently skipping remote cleanup on every trim. Repeated repository hiccups would then let remote generations accumulate unboundedly without any external signal beyond a warn log — worth exposing via a metric or bounded retry.

// Deleting remote generations mutates state shared with any other live copy of this shard, and is not on the
// acknowledgement path the fence CAS gates, so it is gated on this copy still owning the fence. A superseded
// copy collecting garbage can remove files a legitimate owner is still recovering from. See FenceSegmentFlow.tla.
// Fails CLOSED, unlike the segment publish gate: a wrongly permitted delete is not recoverable, so an
// unreadable fence skips the cleanup rather than proceeding with it.
try {
    if (isRemoteStoreFenceSuperseded()) {
        logger.info("Skipping remote translog cleanup: a higher primary term has taken the remote store fence");
        return;
    }
} catch (IOException e) {
    logger.warn("Could not determine remote store fence ownership; skipping remote translog cleanup", e);
    return;
}

⚠️ Review coverage: The following files were not included in this review because of the token budget:

  • server/src/main/java/org/opensearch/common/blobstore/BlobContainer.java
  • server/src/main/java/org/opensearch/index/translog/TranslogConfig.java
  • server/src/test/java/org/opensearch/action/bulk/TransportShardBulkActionTests.java
  • server/src/test/java/org/opensearch/index/translog/InternalTranslogManagerTests.java
  • server/src/main/java/org/opensearch/cluster/metadata/MetadataCreateIndexService.java
  • server/src/main/java/org/opensearch/index/IndexSettings.java
  • server/src/main/java/org/opensearch/index/translog/RemoteStoreFenceOwnership.java
  • server/src/test/java/org/opensearch/cluster/metadata/MetadataCreateIndexServiceTests.java
  • server/src/main/java/org/opensearch/common/blobstore/VersionedBlob.java
  • server/src/main/java/org/opensearch/action/bulk/TransportShardBulkAction.java
  • server/src/main/java/org/opensearch/index/translog/RemoteFsTimestampAwareTranslog.java
  • server/src/main/java/org/opensearch/indices/RemoteStoreSettings.java
  • server/src/main/java/org/opensearch/repositories/fs/ReloadableFsRepository.java
  • test/framework/src/main/java/org/opensearch/remotestore/multipart/mocks/MockFsAsyncBlobContainer.java
  • server/src/main/java/org/opensearch/index/translog/transfer/TranslogFencedException.java
  • server/src/main/java/org/opensearch/common/blobstore/BlobVersionConflictException.java
  • server/src/main/java/org/opensearch/common/settings/ClusterSettings.java
  • server/src/main/java/org/opensearch/common/settings/IndexScopedSettings.java
  • formal-models/remote-store-fence/tla/FenceTakeover.tla
  • formal-models/remote-store-fence/README.md
  • formal-models/remote-store-fence/tla/RemoteStoreFence.tla
  • formal-models/remote-store-fence/tla/FenceSegmentFlow.tla
  • formal-models/remote-store-fence/tla/FenceHandoff.tla
  • formal-models/remote-store-fence/tla/FenceTakeover.cfg
  • formal-models/remote-store-fence/tla/RemoteStoreFence.cfg
  • formal-models/remote-store-fence/tla/FenceSegmentFlow.cfg
  • formal-models/remote-store-fence/tla/FenceHandoff.cfg

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to 6e63b54

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Fix highestTerm filter making isSuperseded always false

isSuperseded(primaryTerm) calls highestTerm(primaryTerm) > primaryTerm, but
highestTerm filters out any max that is not strictly greater than floor and returns
floor in that case — so the return value can never be greater than floor, making
isSuperseded always return false. The filter should be removed (or the comparison in
isSuperseded reworked) so that a strictly higher term is actually returned.

server/src/main/java/org/opensearch/index/translog/transfer/RemoteStoreFence.java [475-477]

 private long highestTerm(long floor) throws IOException {
-    return listTerms().stream().max(Long::compareTo).filter(t -> t > floor).orElse(floor);
+    return listTerms().stream().max(Long::compareTo).orElse(floor);
 }
Suggestion importance[1-10]: 9

__

Why: Correct and critical: highestTerm(floor) filters out values not strictly greater than floor and returns floor otherwise, so isSuperseded (which compares highestTerm(primaryTerm) > primaryTerm) can never return true. This breaks the segment-flow and GC gates that rely on isSuperseded, a central invariant of the PR.

High
Propagate fencing exception instead of swallowing it

When ex is a TranslogFencedException, returning false after invoking onUploadFailed
causes transferSnapshot to return false instead of propagating the fencing
exception. Callers in RemoteFsTranslog.upload/prepareAndUpload only set the tragic
exception and close the translog inside catch (TranslogFencedException), so a false
return will not trigger the tragic-event close path and the shard will not be failed
as designed. Rethrow the fencing exception here so the caller's catch block runs.

server/src/main/java/org/opensearch/index/translog/transfer/TranslogTransferManager.java [324-328]

-Exception exWithoutSuppressed = ex instanceof TranslogFencedException
-    ? new TranslogFencedException(ex.getMessage())
-    : new TranslogUploadFailedException(ex.getMessage());
+if (ex instanceof TranslogFencedException) {
+    TranslogFencedException fenced = new TranslogFencedException(ex.getMessage());
+    translogTransferListener.onUploadFailed(transferSnapshot, fenced);
+    throw fenced;
+}
+Exception exWithoutSuppressed = new TranslogUploadFailedException(ex.getMessage());
 translogTransferListener.onUploadFailed(transferSnapshot, exWithoutSuppressed);
 return false;
Suggestion importance[1-10]: 8

__

Why: Strong catch: returning false on a TranslogFencedException bypasses the caller's catch (TranslogFencedException) in RemoteFsTranslog.upload/prepareAndUpload which sets the tragic exception and closes the translog. Without rethrowing, the shard-failing path documented throughout the PR would not trigger, undermining the fencing invariant.

Medium
General
Enforce size bound on conditional write payload

inputStream.readAllBytes() ignores the caller-supplied blobSize and can buffer
arbitrarily large streams into memory, contradicting the "small control blobs only"
contract stated on the API and enforced by the S3 implementation. Validate blobSize
against blobStore.bufferSizeInBytes() (as S3BlobContainer does) before reading, to
prevent OOMs from misuse.

server/src/main/java/org/opensearch/common/blobstore/fs/FsBlobContainer.java [379-388]

 @Override
 public String writeBlobConditionally(String blobName, InputStream inputStream, long blobSize, @Nullable String expectedVersionToken)
     throws IOException {
+    if (blobSize > blobStore.bufferSizeInBytes()) {
+        throw new IllegalArgumentException("Conditional write request size [" + blobSize + "] can't be larger than buffer size");
+    }
     synchronized (conditionalWriteLock(blobName)) {
         final String currentVersionToken = currentVersionToken(blobName);
         if (Objects.equals(expectedVersionToken, currentVersionToken) == false) {
             throw new BlobVersionConflictException(
                 "conditional write conflict for blob [" + blobName + "]: expected [" + expectedVersionToken + "]"
             );
         }
         final byte[] content = inputStream.readAllBytes();
Suggestion importance[1-10]: 5

__

Why: Valid defensive improvement: readAllBytes() ignores blobSize and can buffer arbitrary streams, contradicting the "small control blobs only" contract that S3BlobContainer enforces. Impact is moderate since this container is used mainly in tests and internal fencing paths.

Low
Record defeating term more precisely

Fenced(w, HighestTerm) records the highest term among all paths, but the fencing
term should be the highest term strictly greater than wTerm[w] that was observed in
the listing, since a lower-term path present alongside a higher-term one does not
constitute the defeat. Using HighestTerm when e.g. paths = {1, 5} and wTerm[w] = 3
still records 5, which is correct, but when paths = {5} and wTerm[w] = 3 it's also 5
— OK. However, this could over-report if a lower-term path exists but no higher-term
path exists (unreachable here due to the guard). Consider using Max({t \in paths : t
> wTerm[w]}) for clarity and to keep NoHigherTermDefeat tight.

formal-models/remote-store-fence/tla/FenceTakeover.tla [185-188]

 ListPaths(w) ==
   /\ wState[w] = "fresh"
   /\ wListed[w] = -1
   /\ IF \E t \in paths : t > wTerm[w]
-       THEN /\ Fenced(w, HighestTerm)
+       THEN /\ Fenced(w, Max({t \in paths : t > wTerm[w]}))
Suggestion importance[1-10]: 3

__

Why: The suggestion itself acknowledges that under the guard \E t \in paths : t > wTerm[w], HighestTerm is always strictly greater than wTerm[w], so the change is only a clarity improvement, not a correctness fix. NoHigherTermDefeat remains tight either way.

Low
Broaden stale-appointment term range coverage

The existential \E t \in 1..(HighestIssuedTerm - 1) binds t only inside the primed
assignment, but t is not used to constrain anything else, so this is fine
semantically. However, the guard HighestIssuedTerm > 1 excludes the case where
HighestIssuedTerm = 1 and a stale appointment at term 1 (below a fence at term 1)
should still be modeled. Consider using >= 1 and range 1..HighestIssuedTerm when a
higher fence term exists, so stale appointments at the same term as prior issuance
are also explored.

formal-models/remote-store-fence/tla/FenceTakeover.tla [157-163]

 AppointStaleTerm(w) ==
   /\ wState[w] = "unborn"
-  /\ HighestIssuedTerm > 1
-  /\ \E t \in 1..(HighestIssuedTerm - 1) : wTerm' = [wTerm EXCEPT ![w] = t]
+  /\ HighestIssuedTerm >= 1
+  /\ \E t \in 1..HighestIssuedTerm : wTerm' = [wTerm EXCEPT ![w] = t]
   /\ wState' = [wState EXCEPT ![w] = "fresh"]
Suggestion importance[1-10]: 2

__

Why: The current guard HighestIssuedTerm > 1 with range 1..(HighestIssuedTerm - 1) deliberately picks a strictly-lower term to model stale appointments. Broadening to include HighestIssuedTerm itself changes the semantics from "stale" to potentially "current" and may or may not be desired; the suggestion is speculative and does not clearly identify a bug.

Low

Previous suggestions

Suggestions up to commit 12ff413
CategorySuggestion                                                                                                                                    Impact
General
Preserve trailing empties when splitting fence blob

String.split drops trailing empty strings by default, so a malformed blob ending
with a separator and empty final field (e.g. an empty seq) will produce fewer than 7
tokens and hit the length check, but a blob with trailing empty allocationId/nodeId
fields might also mis-parse. More importantly, pass a limit of -1 to split to
preserve trailing empty fields so the length check is reliable, and reject empty
required string fields explicitly.

server/src/main/java/org/opensearch/index/translog/transfer/RemoteStoreFence.java [779-782]

 static FenceState parse(byte[] content) throws IOException {
-    String[] tokens = new String(content, StandardCharsets.UTF_8).split("\\" + FIELD_SEPARATOR);
+    String[] tokens = new String(content, StandardCharsets.UTF_8).split("\\" + FIELD_SEPARATOR, -1);
     if (tokens.length != 7 || CODEC_VERSION.equals(tokens[0]) == false) {
         throw new IOException("Unrecognized fence blob content");
     }
Suggestion importance[1-10]: 7

__

Why: Correct observation: String.split without a limit drops trailing empty strings, so a malformed blob with an empty trailing field (e.g. empty seq) would produce fewer than 7 tokens but for different reasons than intended. Using -1 limit makes the length check reliable and defensive against subtle parse bugs.

Medium
Use Content-Range to detect oversized blobs reliably

The ranged GET bytes=0- requests sizeBound + 1 bytes (HTTP ranges are inclusive), so
content.length can be exactly sizeBound + 1 for a blob that is >= sizeBound + 1
bytes. The check content.length > sizeBound correctly rejects that. However, for a
blob of exactly sizeBound bytes the response will be sizeBound bytes, which passes.
This is consistent with tests. But note: for a blob larger than sizeBound + 1 the
response body is truncated to sizeBound + 1 bytes — there is no way to distinguish
"exactly sizeBound + 1" from "much larger". Consider inspecting the Content-Range
response header (via responseBytes.response().contentRange()) to detect truncation
reliably rather than inferring size from the returned length.

plugins/repository-s3/src/main/java/org/opensearch/repositories/s3/S3BlobContainer.java [266-273]

 final ResponseBytes<GetObjectResponse> responseBytes = AccessController.doPrivileged(
     () -> clientReference.get().getObjectAsBytes(getObjectRequest)
 );
 final byte[] content = responseBytes.asByteArray();
 if (content.length > sizeBound) {
     throw new IOException("[" + blobName + "] blob of size > [" + sizeBound + "] is too large for a versioned read");
 }
+// Optionally verify Content-Range to detect truncation of a larger blob explicitly.
Suggestion importance[1-10]: 4

__

Why: The current check already correctly rejects oversized blobs (bytes=0-N returns N+1 bytes, so any blob >= N+1 bytes returns exactly N+1 bytes which trips the > sizeBound check). Inspecting Content-Range is a minor robustness improvement but not strictly necessary.

Low
Also invalidate token at exact container path

The delete() method also needs to invalidate the token for a blob at the container's
own path (if any) — currently only descendants are removed because the prefix
includes a trailing separator. If a conditionally-written blob exists at exactly
path (unlikely but not impossible), its token would leak. Also consider that
CONDITIONAL_WRITE_VERSIONS is a static map shared across all FsBlobStore instances
in the JVM, which can cause cross-test pollution; documenting or clearing it between
stores would be safer.

server/src/main/java/org/opensearch/common/blobstore/fs/FsBlobContainer.java [164-165]

-final String containerPrefix = path.toAbsolutePath().toString() + path.getFileSystem().getSeparator();
-CONDITIONAL_WRITE_VERSIONS.keySet().removeIf(key -> key.startsWith(containerPrefix));
+final String containerPath = path.toAbsolutePath().toString();
+final String containerPrefix = containerPath + path.getFileSystem().getSeparator();
+CONDITIONAL_WRITE_VERSIONS.keySet().removeIf(key -> key.equals(containerPath) || key.startsWith(containerPrefix));
Suggestion importance[1-10]: 3

__

Why: The edge case (a conditionally-written blob at the container path itself) is highly unlikely since containers are directories, not blobs. The static-map cross-test-pollution concern is a valid but minor observation.

Low
Make fenced-by witness explicit

Fenced(w, HighestTerm) records the highest term in paths, but the branch was entered
because some t > wTerm[w] exists in paths. If HighestTerm were somehow computed as
less than wTerm[w] (e.g., due to future refactors), NoHigherTermDefeat could be
violated. Consider Fenced(w, Max({t \in paths : t > wTerm[w]})) to make the
invariant witness explicit and robust.

formal-models/remote-store-fence/tla/FenceTakeover.tla [185-186]

 ListPaths(w) ==
   /\ wState[w] = "fresh"
   /\ wListed[w] = -1
   /\ IF \E t \in paths : t > wTerm[w]
-       THEN /\ Fenced(w, HighestTerm)
+       THEN /\ Fenced(w, Max({t \in paths : t > wTerm[w]}))
Suggestion importance[1-10]: 3

__

Why: Since the branch is guarded by \E t \in paths : t > wTerm[w], HighestTerm is guaranteed to be greater than wTerm[w], so the suggestion is a defensive refactor rather than a bug fix. It has marginal robustness value.

Low
Clarify nondeterministic term choice scoping

The existential quantifier over t binds t inside wTerm', but t is not otherwise
referenced, and TLC evaluates the primed assignment as an equality. The lower bound
1..(HighestIssuedTerm - 1) also excludes term 0 arbitrarily; consider
0..(HighestIssuedTerm - 1) or documenting why term 0 is excluded, and ensure the \E
pattern is expressing the intended nondeterministic choice of t.

formal-models/remote-store-fence/tla/FenceTakeover.tla [157-163]

 AppointStaleTerm(w) ==
   /\ wState[w] = "unborn"
   /\ HighestIssuedTerm > 1
-  /\ \E t \in 1..(HighestIssuedTerm - 1) : wTerm' = [wTerm EXCEPT ![w] = t]
+  /\ \E t \in 1..(HighestIssuedTerm - 1) :
+       /\ wTerm' = [wTerm EXCEPT ![w] = t]
   /\ wState' = [wState EXCEPT ![w] = "fresh"]
Suggestion importance[1-10]: 2

__

Why: The existing \E t \in 1..(HighestIssuedTerm - 1) : wTerm' = [wTerm EXCEPT ![w] = t] is valid TLA+ syntax expressing nondeterministic choice; the suggested improved code is essentially identical (just adds a /\), and the concern about term 0 exclusion is minor and speculative.

Low
Possible issue
Report executor rejection to the listener

If threadPool.executor(...).execute(...) throws (e.g. RejectedExecutionException
when the executor is shutting down), the caller is left waiting on a listener that
will never fire, which will trip the awaitFenceValidation timeout at best and hang
callers at worst. Wrap the execute call itself so a synchronous rejection surfaces
via listener.onFailure immediately.

server/src/main/java/org/opensearch/index/translog/transfer/RemoteStoreFence.java [229-242]

 public void validateAndAdvanceAsync(long primaryTerm, ActionListener<Void> listener) {
-    threadPool.executor(ThreadPool.Names.TRANSLOG_TRANSFER).execute(() -> {
-        try {
-            validateAndAdvance(primaryTerm);
-        } catch (Exception e) {
-            listener.onFailure(e);
-            return;
-        }
-        // Deliberately outside the catch. A listener that throws is a bug in the listener, not a fence failure, and
-        // routing it to onFailure would both deliver two terminal callbacks and misreport a CAS that actually
-        // succeeded as an upload failure. Let it reach the executor's uncaught handler instead.
-        listener.onResponse(null);
-    });
+    try {
+        threadPool.executor(ThreadPool.Names.TRANSLOG_TRANSFER).execute(() -> {
+            try {
+                validateAndAdvance(primaryTerm);
+            } catch (Exception e) {
+                listener.onFailure(e);
+                return;
+            }
+            listener.onResponse(null);
+        });
+    } catch (Exception e) {
+        listener.onFailure(e);
+    }
 }
Suggestion importance[1-10]: 7

__

Why: Valid concern: a RejectedExecutionException from execute during shutdown would leave the caller hanging until timeout. Wrapping the execute call to route rejections to onFailure is a genuine reliability improvement.

Medium
Suggestions up to commit 61ef914
CategorySuggestion                                                                                                                                    Impact
Possible issue
Detect fenced exception through cause chain

The TranslogFencedException wrapping via awaitFenceValidation puts the original
exception in getCause() (through TranslogUploadFailedException("Fence validation
failed", exception)), but here the instanceof TranslogFencedException check only
matches when the exception is directly fenced. If the fence CAS produces a
non-TranslogFencedException IOException, it is wrapped and the fenced-type
propagation may not fire correctly - and a TranslogFencedException that was itself
wrapped as a cause of TranslogUploadFailedException will be misclassified as
retryable. Unwrap via ExceptionsHelper.unwrap or check the cause chain.

server/src/main/java/org/opensearch/index/translog/transfer/TranslogTransferManager.java [319-329]

 } catch (Exception ex) {
     logger.error(() -> new ParameterizedMessage("Transfer failed for snapshot {}", transferSnapshot), ex);
     captureStatsOnUploadFailure();
-    // Preserve the fenced exception type: callers treat fencing as fatal for the shard, not a retryable
-    // upload failure.
-    Exception exWithoutSuppressed = ex instanceof TranslogFencedException
-        ? new TranslogFencedException(ex.getMessage())
+    TranslogFencedException fenced = (TranslogFencedException) ExceptionsHelper.unwrap(ex, TranslogFencedException.class);
+    Exception exWithoutSuppressed = fenced != null
+        ? new TranslogFencedException(fenced.getMessage())
         : new TranslogUploadFailedException(ex.getMessage());
     translogTransferListener.onUploadFailed(transferSnapshot, exWithoutSuppressed);
     return false;
 }
Suggestion importance[1-10]: 8

__

Why: Strong catch: awaitFenceValidation wraps a TranslogFencedException inside a TranslogUploadFailedException when the exception is not an IOException, and even for the direct throw path the fence can arrive as a cause. The instanceof check will then misclassify a fenced condition as retryable, defeating the fatal-on-fence contract the PR relies on. Unwrapping the cause chain is important for correctness.

Medium
Preserve empty fields when splitting fence blob

String.split without an explicit limit discards trailing empty strings, so a blob
whose last field (seq) is empty would be reported with fewer than 7 tokens - but a
blob whose middle field (e.g. allocationId or nodeId) is empty would silently
collapse to a shorter array and could be misparsed. Pass a negative limit to split
so empty trailing/middle fields are preserved and the length check catches all
malformed content.

server/src/main/java/org/opensearch/index/translog/transfer/RemoteStoreFence.java [779-782]

 static FenceState parse(byte[] content) throws IOException {
-    String[] tokens = new String(content, StandardCharsets.UTF_8).split("\\" + FIELD_SEPARATOR);
+    String[] tokens = new String(content, StandardCharsets.UTF_8).split("\\" + FIELD_SEPARATOR, -1);
     if (tokens.length != 7 || CODEC_VERSION.equals(tokens[0]) == false) {
         throw new IOException("Unrecognized fence blob content");
     }
Suggestion importance[1-10]: 7

__

Why: Valid concern: String.split without a limit drops trailing empties, so a blob with an empty seq field would produce a 6-token array and be rejected as malformed rather than parsed - but more importantly it could mask empty middle fields depending on layout. Using split(..., -1) makes the length check reliable. Moderate impact since fields are validated to not contain the separator on write.

Medium
General
Handle executor rejection to avoid latch hang

If threadPool.executor(...).execute(...) throws a RejectedExecutionException (e.g.,
during shutdown), the caller's CountDownLatch in awaitFenceValidation will never be
counted down and the upload will block until the timeout. Catch the rejection at
submission time and route it to listener.onFailure so the caller gets a prompt,
deterministic error instead of a 30s+ timeout.

server/src/main/java/org/opensearch/index/translog/transfer/RemoteStoreFence.java [229-242]

 public void validateAndAdvanceAsync(long primaryTerm, ActionListener<Void> listener) {
-    threadPool.executor(ThreadPool.Names.TRANSLOG_TRANSFER).execute(() -> {
-        try {
-            validateAndAdvance(primaryTerm);
-        } catch (Exception e) {
-            listener.onFailure(e);
-            return;
-        }
-        // Deliberately outside the catch. A listener that throws is a bug in the listener, not a fence failure, and
-        // routing it to onFailure would both deliver two terminal callbacks and misreport a CAS that actually
-        // succeeded as an upload failure. Let it reach the executor's uncaught handler instead.
-        listener.onResponse(null);
-    });
+    try {
+        threadPool.executor(ThreadPool.Names.TRANSLOG_TRANSFER).execute(() -> {
+            try {
+                validateAndAdvance(primaryTerm);
+            } catch (Exception e) {
+                listener.onFailure(e);
+                return;
+            }
+            listener.onResponse(null);
+        });
+    } catch (Exception e) {
+        listener.onFailure(e);
+    }
 }
Suggestion importance[1-10]: 6

__

Why: Valid edge case: if execute throws RejectedExecutionException (shutdown), the caller's latch never counts down and the upload waits for the full remote transfer timeout. Catching submission-time rejection is a reasonable robustness improvement.

Low
Enforce declared blob size on conditional write

inputStream.readAllBytes() ignores the caller-supplied blobSize, so a stream
returning more or fewer bytes than declared will be written silently with the wrong
length. This diverges from the S3 implementation, which enforces blobSize, and from
every other write method in this container. Read exactly blobSize bytes (or use
Streams.copy with the declared length) and fail if the stream length disagrees.

server/src/main/java/org/opensearch/common/blobstore/fs/FsBlobContainer.java [386]

 synchronized (conditionalWriteLock(blobName)) {
     final String currentVersionToken = currentVersionToken(blobName);
     if (Objects.equals(expectedVersionToken, currentVersionToken) == false) {
         throw new BlobVersionConflictException(
             "conditional write conflict for blob [" + blobName + "]: expected [" + expectedVersionToken + "]"
         );
     }
-    final byte[] content = inputStream.readAllBytes();
+    final byte[] content = inputStream.readNBytes(Math.toIntExact(blobSize));
+    if (content.length != blobSize || inputStream.read() != -1) {
+        throw new IOException("input stream length does not match declared blobSize [" + blobSize + "]");
+    }
Suggestion importance[1-10]: 5

__

Why: Reasonable consistency improvement: using readAllBytes() ignores the declared blobSize, differing from the S3 implementation which enforces it. Mostly a robustness/consistency concern rather than a correctness bug in the current fence usage.

Low
Include equal stale term in appointment range

The action requires HighestIssuedTerm > 1 but the range 1..(HighestIssuedTerm - 1)
excludes terms equal to HighestIssuedTerm, so "at or below one already issued" is
not fully covered — an equal stale term is never explored. Consider
1..HighestIssuedTerm to match the comment's "at or below" semantics and exercise the
term-floor refusal at equality.

formal-models/remote-store-fence/tla/FenceTakeover.tla [157-163]

 AppointStaleTerm(w) ==
   /\ wState[w] = "unborn"
-  /\ HighestIssuedTerm > 1
-  /\ \E t \in 1..(HighestIssuedTerm - 1) : wTerm' = [wTerm EXCEPT ![w] = t]
+  /\ HighestIssuedTerm >= 1
+  /\ \E t \in 1..HighestIssuedTerm : wTerm' = [wTerm EXCEPT ![w] = t]
   /\ wState' = [wState EXCEPT ![w] = "fresh"]
Suggestion importance[1-10]: 5

__

Why: The comment says "at or below" but the range 1..(HighestIssuedTerm - 1) excludes equality. However, an equal term is already handled by AppointRelocation/AppointFailover semantics, so the impact is moderate and mainly a comment/spec alignment issue.

Low
Reset ownership when withdrawing own path

When withdrawing the own path on supersession, paths' is updated but
pathToken/pathOwner for wTerm[w] are not cleared, leaving stale ownership/token
state that could allow spurious later matches (e.g. via pathOwner[wTerm[w]] = w in
ReAdoptList if state ever revisited). Consider resetting these entries alongside
paths' for consistency, or document why leaving them is safe.

formal-models/remote-store-fence/tla/FenceTakeover.tla [253-264]

 VerifyClaim(w) ==
   /\ wState[w] = "fresh"
   /\ wObserved[w] = -1
   /\ wTerm[w] \in paths /\ wHeld[w] = pathToken[wTerm[w]]
-  \* A surviving lower-term path means that copy can still acknowledge, so a claim may not be
-  \* completed until the sweep has demonstrably landed.
   /\ ~(\E t \in paths : t < wTerm[w])
   /\ IF \E t \in paths : t > wTerm[w]
        THEN /\ paths' = IF wCreated[w] THEN paths \ {wTerm[w]} ELSE paths
+            /\ pathOwner' = IF wCreated[w] THEN [pathOwner EXCEPT ![wTerm[w]] = NoWriter] ELSE pathOwner
             /\ Fenced(w, Max(paths \cup {0}))
Suggestion importance[1-10]: 3

__

Why: Once the writer transitions to fenced (terminal), ReAdoptList requires wState[w] = "read" and cannot revisit, so leaving pathOwner stale appears safe. The suggestion is a minor cleanliness improvement rather than a correctness fix.

Low
Suggestions up to commit c8faa16
CategorySuggestion                                                                                                                                    Impact
General
Bound conditional write read to declared size

inputStream.readAllBytes() ignores the caller-supplied blobSize and can materialize
an unbounded payload into memory if the stream misreports its size or is
unexpectedly large. Read exactly blobSize bytes (e.g. via
inputStream.readNBytes((int) blobSize) with a size cap check) to bound memory usage
and match the contract of the blobSize parameter.

server/src/main/java/org/opensearch/common/blobstore/fs/FsBlobContainer.java [386-387]

-final byte[] content = inputStream.readAllBytes();
+if (blobSize > Integer.MAX_VALUE) {
+    throw new IOException("blob size [" + blobSize + "] too large for conditional write");
+}
+final byte[] content = inputStream.readNBytes((int) blobSize);
+if (content.length != blobSize) {
+    throw new IOException("expected [" + blobSize + "] bytes but read [" + content.length + "]");
+}
 final String tempBlob = tempBlobName(blobName);
Suggestion importance[1-10]: 5

__

Why: Using readAllBytes() instead of bounding by blobSize is a minor robustness concern for the emulated conditional write path. The fence blobs are small so impact is limited, but reading exactly blobSize bytes better matches the API contract.

Low
Guard token comparison against null values

versionToken can be null here when the create-if-absent CAS conflicted and
arbitrateSameTerm exhausted retries without setting a token (though it throws) — but
more importantly, on the created == false branch after arbitration succeeded,
versionToken is set. However, if arbitration threw and was caught elsewhere this
could NPE. Reverse the equality check to call equals on current.versionToken() or
use Objects.equals to guard against null on either side.

server/src/main/java/org/opensearch/index/translog/transfer/RemoteStoreFence.java [374]

-if (versionToken.equals(current.versionToken()) == false) {
+if (Objects.equals(versionToken, current.versionToken()) == false) {
     FenceState twin = readRemoteState(current.content());
     fenced = true;
Suggestion importance[1-10]: 3

__

Why: At this point versionToken has just been set by the successful cas call (either create or arbitration), so it cannot be null when this comparison runs. The suggestion is defensive but does not address a real bug.

Low
Broaden stale-term appointment coverage

The existential over t is used as a primed-variable assignment inside \E, which is a
subtle TLA+ idiom that TLC accepts but is fragile. Additionally, the lower bound 1
excludes t = 0, which may or may not be intended, but combined with
HighestIssuedTerm > 1 this action is disabled until at least term 2 is issued,
preventing exploration of a stale-term appointment against term 1. Consider
HighestIssuedTerm >= 1 and clarifying the bound.

formal-models/remote-store-fence/tla/FenceTakeover.tla [157-163]

 AppointStaleTerm(w) ==
   /\ wState[w] = "unborn"
-  /\ HighestIssuedTerm > 1
-  /\ \E t \in 1..(HighestIssuedTerm - 1) : wTerm' = [wTerm EXCEPT ![w] = t]
-  /\ wState' = [wState EXCEPT ![w] = "fresh"]
+  /\ HighestIssuedTerm >= 1
+  /\ \E t \in 1..HighestIssuedTerm :
+       /\ wTerm' = [wTerm EXCEPT ![w] = t]
+       /\ wState' = [wState EXCEPT ![w] = "fresh"]
Suggestion importance[1-10]: 3

__

Why: The suggestion correctly notes that HighestIssuedTerm > 1 excludes exploring stale appointments at term 1, but this is a minor coverage concern in an exhaustive model checker with additional interleavings covered elsewhere. The \E t : wTerm' = ... idiom is standard TLA+ and not actually fragile.

Low
Add invariant guarding supersession implications

HigherTermTakeover deletes the shared object but leaves srcState/tgtState unchanged,
so SourceLive/TargetLive become false (since SourceOwns requires fenceExists) —
good. However, srcState = "resumed" remains reachable in later transitions after
supersession, and TargetAcksAreDurable only excludes superseded. Verify that once
superseded = TRUE, the source cannot transition to resumed via AbortHandoff (its
guard fenceExists /\ srcToken = fenceToken correctly fails, so this is safe), but
consider asserting superseded => srcState /= "resumed" as an additional invariant to
catch regressions.

formal-models/remote-store-fence/tla/FenceHandoff.tla [232-237]

 HigherTermTakeover ==
   /\ superseded = FALSE
   /\ superseded' = TRUE
   /\ fenceExists' = FALSE
   /\ UNCHANGED <<fenceOwner, fenceToken, srcToken, tgtToken, srcState, tgtState, handoff,
                  attempts, acked, ackedBySource, ackedByTarget, nextOp>>
+\* Consider adding invariant: SupersededSourceStandsDown == superseded => srcState /= "resumed"
Suggestion importance[1-10]: 3

__

Why: The suggestion acknowledges the current behavior is already safe due to the AbortHandoff guard. Adding a redundant invariant as regression protection is a minor improvement, not addressing an actual bug.

Low
Ensure fenced exception type is preserved

TranslogFencedException extends TranslogUploadFailedException (an IOException), and
prepareAndUpload already has a broader catch/handling flow elsewhere. If any
wrapping code converts the fenced exception into a different type (e.g. via listener
rewrap), this catch will be bypassed and closeOnTragicEvent will not run. Consider
also catching by inspecting tragedy.get() after the try block, or ensure the
exception type is preserved end-to-end (as is done in
TranslogTransferManager#transferSnapshot).

server/src/main/java/org/opensearch/index/translog/RemoteFsTranslog.java [651-656]

 } catch (TranslogFencedException ex) {
-    // The tragic exception is set by upload(); close here, where the resources of the try-with-resources above
-    // (notably the read lock) have already been released - closeOnTragicEvent acquires the write lock.
     closeOnTragicEvent(ex);
     throw ex;
 }
Suggestion importance[1-10]: 2

__

Why: The suggestion is speculative and the improved_code is essentially identical to the existing_code. The PR already preserves the fenced exception type in TranslogTransferManager#transferSnapshot, so this catch works correctly.

Low
Guard create against late higher-term paths

CreatePath does not re-check that no higher-term path has appeared since ListPaths
ran. Between listing and creation, another writer could have created a higher-term
path (via appointment + create). Creating your own path in that case still succeeds
because keys differ by term, but the subsequent VerifyClaim will catch it. Consider
adding a comment noting this is intentional, or add the higher-term guard here to
fail earlier and avoid a spurious create+withdraw cycle.

formal-models/remote-store-fence/tla/FenceTakeover.tla [193-198]

 CreatePath(w) ==
   /\ wState[w] = "fresh"
   /\ wListed[w] /= -1
   /\ wTook[w] = FALSE
   /\ wObserved[w] = -1
+  /\ ~(\E t \in paths : t > wTerm[w])  \* re-check; VerifyClaim will also catch late arrivals
   /\ IF wTerm[w] \in paths
Suggestion importance[1-10]: 2

__

Why: Adding this guard would restrict reachable behaviors that the author explicitly wants explored (VerifyClaim catches this). The suggestion itself acknowledges this is optional and mostly about earlier failure detection, offering marginal value.

Low
Suggestions up to commit 5827e62
CategorySuggestion                                                                                                                                    Impact
Possible issue
Handle executor rejection in async fence advance

If the executor rejects the task (e.g. shutdown or queue full), the listener is
never invoked, which will hang the caller waiting on the latch in
TranslogTransferManager#awaitFenceValidation until the transfer timeout. Wrap the
execute call in a try/catch and fail the listener on RejectedExecutionException so
rejection surfaces promptly.

server/src/main/java/org/opensearch/index/translog/transfer/RemoteStoreFence.java [229-242]

 public void validateAndAdvanceAsync(long primaryTerm, ActionListener<Void> listener) {
-    threadPool.executor(ThreadPool.Names.TRANSLOG_TRANSFER).execute(() -> {
-        try {
-            validateAndAdvance(primaryTerm);
-        } catch (Exception e) {
-            listener.onFailure(e);
-            return;
-        }
-        // Deliberately outside the catch. A listener that throws is a bug in the listener, not a fence failure, and
-        // routing it to onFailure would both deliver two terminal callbacks and misreport a CAS that actually
-        // succeeded as an upload failure. Let it reach the executor's uncaught handler instead.
-        listener.onResponse(null);
-    });
+    try {
+        threadPool.executor(ThreadPool.Names.TRANSLOG_TRANSFER).execute(() -> {
+            try {
+                validateAndAdvance(primaryTerm);
+            } catch (Exception e) {
+                listener.onFailure(e);
+                return;
+            }
+            listener.onResponse(null);
+        });
+    } catch (Exception rejected) {
+        listener.onFailure(rejected);
+    }
 }
Suggestion importance[1-10]: 7

__

Why: Valid concern: if the executor rejects the task, the listener is never invoked, which would cause awaitFenceValidation to hang until timeout. Wrapping the execute call in try/catch is a reasonable defensive improvement.

Medium
General
Fix prefix match to avoid sibling collisions

Using startsWith(containerPrefix) without a trailing separator can incorrectly
invalidate version tokens for sibling containers whose absolute paths share a prefix
(e.g. /repo/foo matches /repo/foobar). Append the filesystem separator to
containerPrefix before the prefix check to scope invalidation to this container's
subtree.

server/src/main/java/org/opensearch/common/blobstore/fs/FsBlobContainer.java [162-163]

-final String containerPrefix = path.toAbsolutePath().toString();
+final String containerPrefix = path.toAbsolutePath().toString() + java.io.File.separator;
 CONDITIONAL_WRITE_VERSIONS.keySet().removeIf(key -> key.startsWith(containerPrefix));
Suggestion importance[1-10]: 6

__

Why: Legitimate correctness concern: startsWith without a trailing separator could match sibling paths like /repo/foo and /repo/foobar, potentially invalidating unrelated version tokens. The fix is minor but correct.

Low
Bound conditional-write payload size

inputStream.readAllBytes() ignores blobSize and can buffer arbitrarily large streams
into memory, contradicting the "small control blobs only" contract and the size
bound enforced on the read side. Enforce the same blobStore.bufferSizeInBytes()
bound on the write path to prevent OOM on misuse.

server/src/main/java/org/opensearch/common/blobstore/fs/FsBlobContainer.java [377-386]

 @Override
 public String writeBlobConditionally(String blobName, InputStream inputStream, long blobSize, @Nullable String expectedVersionToken)
     throws IOException {
+    if (blobSize > blobStore.bufferSizeInBytes()) {
+        throw new IllegalArgumentException(
+            "Conditional write request size [" + blobSize + "] can't be larger than buffer size"
+        );
+    }
     synchronized (conditionalWriteLock(blobName)) {
         final String currentVersionToken = currentVersionToken(blobName);
         if (Objects.equals(expectedVersionToken, currentVersionToken) == false) {
             throw new BlobVersionConflictException(
                 "conditional write conflict for blob [" + blobName + "]: expected [" + expectedVersionToken + "]"
             );
         }
         final byte[] content = inputStream.readAllBytes();
Suggestion importance[1-10]: 5

__

Why: Reasonable defensive check to match the read-side bound and prevent OOM, aligning with the S3 implementation which already enforces this. However, the emulation is test-only and the risk is limited.

Low
Guard revert against target adoption state

AbortHandoff forcibly sets tgtState' = "lost" even when the target may already be
"activated" or "serving" and have adopted the chain. If the revert CAS succeeded
(srcToken = fenceToken), it means the target had not yet adopted (adoption bumps
fenceToken), but the current model still overwrites tgtState unconditionally, which
may mask states where a target is genuinely serving. Consider guarding the tgtState'
update to only occur when the target has not adopted, or asserting that tgtToken =
-1 in that branch to make the invariant explicit.

formal-models/remote-store-fence/tla/FenceHandoff.tla [202-219]

 AbortHandoff ==
-  \* "none" as well as "lost": the implementation reclaims on ANY failure after the transfer, which
-  \* includes startRelocationHandoff throwing before the context was ever sent. That case is strictly
-  \* easier - the target cannot have written - but it is a reachable path, so model it rather than
-  \* leaving it outside the spec.
   /\ handoff \in {"none", "lost"}
   /\ srcState = "transferred"
   /\ IF fenceExists /\ srcToken = fenceToken
-       THEN \* The target never wrote, so it never took over: reclaim and resume.
-         /\ fenceOwner' = "source"
-         /\ fenceToken' = fenceToken + 1
-         /\ srcToken' = fenceToken + 1
-         /\ srcState' = "resumed"
-         /\ handoff' = "none"
-         /\ tgtState' = "lost"
-         /\ tgtToken' = -1
-         /\ UNCHANGED <<fenceExists, attempts, superseded, acked, ackedBySource, ackedByTarget,
-                        nextOp>>
+       THEN /\ tgtToken = -1  \* target cannot have adopted if revert CAS is valid
+            /\ fenceOwner' = "source"
+            /\ fenceToken' = fenceToken + 1
+            /\ srcToken' = fenceToken + 1
+            /\ srcState' = "resumed"
+            /\ handoff' = "none"
+            /\ tgtState' = "lost"
+            /\ tgtToken' = -1
+            /\ UNCHANGED <<fenceExists, attempts, superseded, acked, ackedBySource, ackedByTarget,
+                           nextOp>>
Suggestion importance[1-10]: 3

__

Why: The observation that target adoption bumps fenceToken (making srcToken = fenceToken imply the target hadn't adopted) is plausible, but the suggested change adds a redundant assertion rather than fixing a real bug, and the existing model behavior appears correct.

Low
Document off-by-one in byte range bound

The HTTP byte range bytes=0-N is inclusive and requests N+1 bytes, so the current
range asks for sizeBound+1 bytes; the subsequent check content.length > sizeBound
then rejects an object of exactly sizeBound bytes only if it received one extra
byte. This matches the write-side bound (blobSize > bufferSizeInBytes())
semantically but the test testReadBlobWithVersionAcceptsBlobExactlyAtSizeBound
asserts bytes=0-" + sizeBound, which is the same asymmetric expression — verify this
is intentional and document why the range extends one byte past the accepted bound
rather than using bytes=0-(sizeBound-1) combined with content.length >= sizeBound
rejection.

plugins/repository-s3/src/main/java/org/opensearch/repositories/s3/S3BlobContainer.java [258-264]

 final long sizeBound = blobStore.bufferSizeInBytes();
+// Range is inclusive; requesting sizeBound+1 bytes lets us detect oversize by checking content.length > sizeBound.
 final GetObjectRequest getObjectRequest = GetObjectRequest.builder()
     .bucket(blobStore.bucket())
     .key(buildKey(blobName))
     .range("bytes=0-" + sizeBound)
     .expectedBucketOwner(blobStore.expectedBucketOwner())
     .build();
Suggestion importance[1-10]: 2

__

Why: The suggestion only asks to verify/document an intentional off-by-one behavior that the PR already tests explicitly (testReadBlobWithVersionAcceptsBlobExactlyAtSizeBound). The improved_code is essentially identical to existing_code with just a comment added.

Low
Verify UNCHANGED tuples cover all variables

In the THEN branch, wFencedTerm is set by Fenced(w, HighestTerm) but is missing from
the UNCHANGED tuple - however wListed IS listed as unchanged even though the ELSE
branch updates it. The current UNCHANGED list in the THEN branch incorrectly
includes wListed (which is fine there since only ELSE updates it), but omits nothing
critical; however, verify that every variable is covered exactly once per branch.
Specifically, wFencedTerm is correctly primed via Fenced, but ensure TLC does not
complain about missing primes - double-check by making the UNCHANGED tuples
exhaustive and disjoint from primed vars.

formal-models/remote-store-fence/tla/FenceTakeover.tla [148-157]

+ListPaths(w) ==
+  /\ wState[w] = "fresh"
+  /\ wListed[w] = -1
+  /\ IF \E t \in paths : t > wTerm[w]
+       THEN /\ Fenced(w, HighestTerm)
+            /\ UNCHANGED <<paths, pathToken, wTerm, wHeld, wObserved, wCreated, wTook, wListed,
+                           restore, hasRead, acked, ackedBy, nextOp>>
+       ELSE /\ wListed' = [wListed EXCEPT ![w] = HighestTerm]
+            /\ UNCHANGED <<paths, pathToken, wTerm, wState, wHeld, wObserved, wCreated, wTook,
+                           wFencedTerm, restore, hasRead, acked, ackedBy, nextOp>>
 
-
Suggestion importance[1-10]: 2

__

Why: The suggestion is vague, asks the user to "verify" without pointing to a concrete bug, and the improved_code is identical to existing_code. Low impact.

Low
Suggestions up to commit 0c6ac19
CategorySuggestion                                                                                                                                    Impact
General
Bound conditional write payload size

writeBlobConditionally reads the full stream into memory with no size check, unlike
readBlobWithVersion which enforces blobStore.bufferSizeInBytes(). An oversized
payload could OOM the JVM or exceed the read-side limit and never be readable again
via readBlobWithVersion. Reject blobSize > blobStore.bufferSizeInBytes() early to
keep the two sides symmetric.

server/src/main/java/org/opensearch/common/blobstore/fs/FsBlobContainer.java [386]

 @Override
 public String writeBlobConditionally(String blobName, InputStream inputStream, long blobSize, @Nullable String expectedVersionToken)
     throws IOException {
+    if (blobSize > blobStore.bufferSizeInBytes()) {
+        throw new IllegalArgumentException(
+            "Conditional write request size [" + blobSize + "] can't be larger than buffer size"
+        );
+    }
     synchronized (conditionalWriteLock(blobName)) {
         final String currentVersionToken = currentVersionToken(blobName);
         if (Objects.equals(expectedVersionToken, currentVersionToken) == false) {
             throw new BlobVersionConflictException(
                 "conditional write conflict for blob [" + blobName + "]: expected [" + expectedVersionToken + "]"
             );
         }
         final byte[] content = inputStream.readAllBytes();
Suggestion importance[1-10]: 7

__

Why: Valid concern: writeBlobConditionally uses readAllBytes() without a size check, while readBlobWithVersion enforces bufferSizeInBytes(). Adding a symmetric bound prevents OOM and matches the S3 implementation's testWriteBlobConditionallyRejectsOversizedPayload behavior.

Medium
Preserve trailing empty fields when parsing

String.split drops trailing empty strings by default, so a blob whose last field
(seq) is empty would either return 6 tokens or otherwise misparse, and even the
Long.parseLong fallback would depend on quirky behaviour. Pass a negative limit to
split so trailing empty tokens are preserved and malformed content fails the length
check deterministically.

server/src/main/java/org/opensearch/index/translog/transfer/RemoteStoreFence.java [742]

 static FenceState parse(byte[] content) throws IOException {
-    String[] tokens = new String(content, StandardCharsets.UTF_8).split("\\" + FIELD_SEPARATOR);
+    String[] tokens = new String(content, StandardCharsets.UTF_8).split("\\" + FIELD_SEPARATOR, -1);
     if (tokens.length != 7 || CODEC_VERSION.equals(tokens[0]) == false) {
         throw new IOException("Unrecognized fence blob content");
     }
Suggestion importance[1-10]: 5

__

Why: Correct observation: String.split without a negative limit drops trailing empty strings, which could cause a malformed blob with empty seq to be misdiagnosed. Using -1 limit is a robustness improvement, though the current length check catches most malformed cases.

Low
Handle in-flight fence CAS on timeout

On timeout the fence CAS is still in flight and may complete later, potentially
advancing the fence chain after the caller has treated the upload as failed.
Consider cancelling the async fence operation on timeout, or documenting/handling
that the fence state may still change post-timeout.

server/src/main/java/org/opensearch/index/translog/transfer/TranslogTransferManager.java [337-344]

 try {
     if (fenceLatch.await(remoteStoreSettings.getClusterRemoteTranslogTransferTimeout().millis(), TimeUnit.MILLISECONDS) == false) {
+        // fence CAS may still complete asynchronously; ensure it cannot advance state unnoticed
         throw new TranslogUploadFailedException("Timed out waiting for fence validation");
     }
 } catch (InterruptedException e) {
     Thread.currentThread().interrupt();
     throw new TranslogUploadFailedException("Interrupted while waiting for fence validation", e);
 }
Suggestion importance[1-10]: 5

__

Why: Legitimate concern that on timeout the fence CAS may still complete asynchronously and mutate state. The suggestion is largely a comment-only change without a concrete cancellation mechanism, limiting its actionable value.

Low
Verify catch ordering for fenced exception

TranslogFencedException extends IOException, so it will already be caught by any
broader catch (IOException) in the surrounding try-with-resources unless this catch
precedes it. Ensure this catch clause is ordered before any IOException catch, or
the tragic-event handling will be bypassed and the shard will retry a fenced upload
instead of failing.

server/src/main/java/org/opensearch/index/translog/RemoteFsTranslog.java [631-636]

 } catch (TranslogFencedException ex) {
-    // The tragic exception is set by upload(); close here, where the resources of the try-with-resources above
-    // (notably the read lock) have already been released - closeOnTragicEvent acquires the write lock.
+    // Must be ordered before any IOException catch: TranslogFencedException extends IOException and this
+    // branch must run to mark the tragic event before the translog is closed.
     closeOnTragicEvent(ex);
     throw ex;
 }
Suggestion importance[1-10]: 3

__

Why: The suggestion only asks to verify ordering without proposing a substantive code change; the improved_code is nearly identical to existing_code. Java catch ordering is compiler-enforced, so this concern is largely handled by the language.

Low
Possible issue
Detect truncated ranged read correctly

**Since the request uses range="bytes=0-" + sizeBound, S3 returns up to sizeBound + 1
bytes (ranges are inclusive), so a blob exactly sizeBound + 1 in size will silently <...

@github-actions

Copy link
Copy Markdown
Contributor

✅ Gradle check result for b14e1a7: SUCCESS

@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 71.47541% with 87 lines in your changes missing coverage. Please review.
✅ Project coverage is 71.63%. Comparing base (baa324b) to head (12ff413).
⚠️ Report is 4 commits behind head on main.

Files with missing lines Patch % Lines
...in/java/org/opensearch/index/shard/IndexShard.java 37.17% 39 Missing and 10 partials ⚠️
...rg/opensearch/index/translog/RemoteFsTranslog.java 79.16% 7 Missing and 3 partials ⚠️
...dex/translog/transfer/TranslogTransferManager.java 74.19% 5 Missing and 3 partials ⚠️
...pensearch/common/blobstore/fs/FsBlobContainer.java 86.95% 6 Missing ⚠️
...index/translog/RemoteFsTimestampAwareTranslog.java 25.00% 5 Missing and 1 partial ⚠️
...search/index/translog/InternalTranslogManager.java 42.85% 1 Missing and 3 partials ⚠️
...h/cluster/metadata/MetadataCreateIndexService.java 87.50% 0 Missing and 1 partial ⚠️
...org/opensearch/common/blobstore/BlobContainer.java 66.66% 1 Missing ⚠️
...search/index/shard/RemoteStoreRefreshListener.java 85.71% 0 Missing and 1 partial ⚠️
...search/repositories/fs/ReloadableFsRepository.java 0.00% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main   #22774      +/-   ##
============================================
+ Coverage     71.60%   71.63%   +0.02%     
- Complexity    77358    77438      +80     
============================================
  Files          6170     6170              
  Lines        359710   359987     +277     
  Branches      52460    52498      +38     
============================================
+ Hits         257583   257882     +299     
+ Misses        81693    81636      -57     
- Partials      20434    20469      +35     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit b47b137

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for b47b137: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit cec3a1b

@github-actions

Copy link
Copy Markdown
Contributor

✅ Gradle check result for cec3a1b: SUCCESS

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 9a7371f

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for 9a7371f: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit b60f8c1

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for b60f8c1: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 069b262

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for 069b262: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

@github-actions

github-actions Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

PR Code Analyzer ❗

AI-powered 'Code-Diff-Analyzer' found issues on commit 1d04612.

Hard block: Issues at Medium severity or above will block this PR from merging.

PathLineSeverityDescription
settings.gradle57highA new Gradle subproject 'formal-models' is added to the build. The accompanying formal-models/build.gradle registers JavaExec tasks (tlcRemoteStoreFence, tlcFenceTakeover, tlcFenceHandoff, tlcFenceSegmentFlow) that execute an externally-supplied JAR (tla2tools.jar) via the JVM. While the build file explicitly requires the caller to supply the JAR and deliberately avoids downloading it, the addition of a new build configuration with executable task registration is a mandatory-flag supply chain surface. Maintainers should verify that the 'base' plugin usage and JavaExec task definitions cannot be triggered unexpectedly in CI pipelines (e.g., via a -PtlaToolsJar argument injected through environment variables or CI parameter files), and that no tooling in the build chain auto-invokes modelCheck.

The table above displays the top 10 most important findings.

Total: 1 | Critical: 0 | High: 1 | Medium: 0 | Low: 0


Pull Requests Author(s): Please update your Pull Request according to the report above.

Repository Maintainer(s): You can bypass diff analyzer by adding label skip-diff-analyzer after reviewing the changes carefully, then re-run failed actions. To re-enable the analyzer, remove the label, then re-run all actions.


⚠️ Note: The Code-Diff-Analyzer helps protect against potentially harmful code patterns. Please ensure you have thoroughly reviewed the changes beforehand.

Thanks.

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for 3525958: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

Segment-replication remote-store indices currently rely on a replica to fence a
stale primary: every write fans out a no-op PRIMARY_TERM_VALIDATION request, and
a partitioned primary is stopped because a replica that has learned a newer term
rejects it. That makes at least one replica a safety requirement, which blocks
zero-replica remote-backed indices from recovering automatically on node loss -
the new primary would begin acknowledging seconds after node-left while the old
one is very likely still alive and unaware.

Make the object store the authority instead. Each shard keeps one small mutable
object per primary term in the translog repository, fence__<invertLong(term)>,
written only by compare-and-swap. The fencing token is the CAS chain, not the
term: every acknowledged translog upload advances it, so a second writer holds a
stale token and its next CAS fails before the write is acknowledged.

Determinism comes from the key space rather than from winning a race. A takeover
lists the paths and refuses if a strictly higher term owns one, creates its own
with create-if-absent - a key no lower-term incumbent ever writes, so the create
cannot be defeated - then deletes every lower-term object unconditionally. That
delete, not the CAS, is what makes a higher-term takeover certain: the incumbent
finds its object gone. A writer's only destructive act is deleting strictly
below itself, so it can never touch a higher-term writer's path.

A recovering primary claims the fence BEFORE reading the translog restore point
it will serve from, which is what closes the acked-write-loss window: everything
the previous primary acknowledged is already in that restore point, and nothing
it attempts afterwards can be acknowledged. Asserted at the read choke point.

The segment flow needs the fence too, which was not obvious and TLC found it.
Term-scoped naming stops readers following a superseded copy's metadata, but
until the new copy publishes its own that metadata is still the latest, and it
defines the reference set garbage collection prunes to - so an unfenced publish
makes the legitimate owner's own collection delete files it is still hydrating.
Publication and collection are both gated on still owning the fence, as a read
rather than a CAS, and fail-open for publication against fail-closed for
collection.

Relocation happens at a constant term, so source and target share one object and
term-scoping cannot arbitrate. The source, which alone can write once it has
drained, performs the ownership transfer as its last act before handing off and
retains the resulting token; on abort it reverts with that token, and whether the
revert succeeds is what distinguishes a target that took over from one that never
wrote. A revert that fails means the target owns the chain, so the source fails
its shard rather than serving on until its next upload loses the CAS.

A conditional write whose response is lost is treated as ambiguous rather than
failed. The write may have landed, leaving this copy holding a stale token whose
next If-Match would fail for exactly the same reason a genuinely fenced writer's
would - failing a healthy primary on a network blip. The ambiguity is resolved by
identity: the object records the writing copy and its seq, so a blob carrying
ours at the seq we attempted can only be the write we just issued, and its token
is adopted. Anything else is reported as not-landed, which is retryable and never
fences.

formal-models/remote-store-fence/ holds four TLA+ specifications, each modelling
the protocol as implemented and each explored to a complete state graph: seal
ordering and acked-write loss, cross-term takeover determinism, the relocation
handoff, and the segment flow with collection. Model checking is opt-in and the
build downloads nothing - see that directory's README for why, and for what the
segment flow deliberately does not cover.

The specifications are the authority. Where the implementation and a model
disagreed, the implementation moved: claim() checks that its sweep landed as an
unconditional precondition rather than on one branch, and equal-term arbitration
fences when the path has gone instead of recreating it. Constraints that come
from the implementation are modelled explicitly rather than assumed away - a
bounded equal-term retry, a batch delete that reports per-key failures instead of
throwing, and an abort that can happen before the primary context was ever sent.

Fencing is off by default, gated per index by a final setting stamped from a
dynamic cluster default at creation time, and refuses any repository whose
conditional writes are not enforced by the store: an emulated precondition would
let two nodes both believe they hold the fence, which is worse than running
unfenced because the shard would report itself protected. Replica term
validation is unchanged, so this adds a second, independent check rather than
replacing the existing one.

Known limitations, deliberate: a fenced writer can leave an orphan metadata file,
since the metadata upload and the CAS are not atomic; it is never acknowledged
and readers following the highest-term lineage ignore it, but reclaiming them is
a follow-up. The fence is control-flow only and is never read by snapshot
restore, pinned-timestamp resolution or garbage collection, so the shallow
snapshot contract is untouched.

Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>

CAS is defined once, on RemoteStoreFence, since the class and all four
specifications are written in its terms: a CAS is a write the store accepts only
if the object's current version is the one presented and refuses otherwise, and
the CAS chain is the succession of tokens one object passes through, where a
write must present the current token and hands back the next - so links cannot
be skipped, holding the current token is what ownership means, and a writer that
misses one can never rejoin. The README glossary and RemoteStoreFence.tla point
at that definition rather than restating it.

Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>
…nSearch into remote-store-fence

# Conflicts:
#	formal-models/remote-store-fence/README.md
#	formal-models/remote-store-fence/tla/RemoteStoreFence.tla
#	server/src/main/java/org/opensearch/index/translog/transfer/RemoteStoreFence.java
Resolving the add/add conflict between the two squashes of this branch left
AwsErrorDetails and ResponseBytes imported twice. Content is otherwise identical
to af8b6f9, the squashed commit.

Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 0c6ac19

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for 0c6ac19: null

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

A takeover claims the fence twice: the recovery seal claims via a
throwaway instance and is discarded, the translog restore point is
read with no live token held, and the translog's own fence instance
re-adopts the chain on its first upload. That re-adoption was
unguarded for a non-relocation copy, so it would take the chain back
from an equal-term twin that legitimately claimed it during the
window - and then serve from a restore point read before the twin's
acknowledgements, losing acked writes.

Require recorded ownership for every translog instance's equal-term
arbitration: the seal records this copy's allocation id, so the
normal flow is unaffected, while a copy whose chain a twin claimed is
fenced and reassigned at a higher term instead of stealing the chain
back. A relocation target already used the identical check to defer
adoption until the source's ownership transfer; this unifies both
under one recorded-ownership rule. Only the seal itself still
arbitrates unguarded, so a new incarnation can take over a dead
incumbent's path.

Found by extending FenceTakeover.tla with the two-take structure: TLC
violates NoAckedWriteLoss in 17 states unguarded, and verifies the
guarded protocol clean at 3- and 4-writer bounds with complete state
graphs.

Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 5827e62

The takeover module modeled a single chain-take per writer, but the
implementation takes the chain twice: the recovery seal claims via a
throwaway instance, the restore point is read with no live token held,
and the translog instance re-adopts on its first upload. Fold that
structure into FenceTakeover.tla - a read (hydrating) state, the
tokenless window, and a ReAdoptList/ReAdoptTake pair guarded by
recorded ownership, matching the previous commit's fix.

Measured, in the module's own style: relaxing the recorded-ownership
guard violates NoAckedWriteLoss in a 17-state trace where a copy
steals the chain back from an equal-term twin and serves from a
restore point read before the twin's acknowledgement. As implemented,
all four invariants hold over a complete state graph of 4,460,896
distinct states at the committed bounds, saturating identically at
MaxTerm 5 and 8.

Also align DeleteLowerPaths with the code: the sweep runs straight
after create/arbitration without re-verifying possession, so guard it
on having taken the path rather than on still holding the current
token - the model now explores that reachable ordering instead of
assuming it away. Update the README numbers and add the two-take
section, and point code comments at FenceTakeover.tla.

Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>
Say what happens instead of naming it: the fence is claimed twice -
first by the throwaway seal, then by the translog instance's
re-adoption. Comments and docs only; TLC re-verified with the
identical 4,460,896-state complete graph.

Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit c8faa16

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for c8faa16: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

Three review findings, in decreasing order of blast radius:

Give stale-segment collection its own fail-closed gate. The publish
gate at the top of the segment sync fails open, and collection sat
behind the same check - so one unreadable-fence event relaxed both,
exactly the combination FenceSegmentFlow.tla measures as violating
HydrationIntegrity. Collection now consults a fail-closed variant
(isRemoteStoreFenceSupersededFailingClosed): an unreadable fence
skips the deletion and retries next cycle, keeping the two failure
directions genuinely independent.

Stop classifying S3 409 ConditionalRequestConflict as a lost CAS. It
means a conflicting conditional write is in progress and the outcome
of this request's precondition is unknown; a retry answers
definitively. Treating it as lost could fence a healthy writer whose
rival lost the race. Only 412, or 409 with PreconditionFailed, now
count as a lost CAS.

Resolve ambiguous ownership-transfer writes by identity. The transfer
and revert of a relocation handoff now adopt their own landed write
after a lost response, as the ack path already did - and the handover
in IndexShard#relocated moved inside the reclaim scope, so a failed
or ambiguous transfer reclaims ownership instead of leaving a
resuming source to be fenced on its next upload.

Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 61ef914

@github-actions

Copy link
Copy Markdown
Contributor

✅ Gradle check result for 61ef914: SUCCESS

The fs container's delete() invalidated conditional-write version
tokens by prefix without a trailing separator, so deleting the
container at /repo/idx-1 also invalidated live tokens under a sibling
like /repo/idx-10 - spuriously fencing an unrelated shard in
integration tests. Append the separator, with a regression test.

Add unit tests for patch-coverage gaps that only integration tests
exercised: the fence-ownership wrappers with and without a fence
(InternalTranslogManager, TranslogTransferManager), the trim gate
skipping remote deletion on a superseded copy, the IndexShard
fence queries with fencing disabled, and the S3 SdkException
fallbacks on the conditional read/write paths.

Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 12ff413

@github-actions

Copy link
Copy Markdown
Contributor

✅ Gradle check result for 12ff413: SUCCESS

The recorded-ownership guard made every translog fence instance take
the same arbitration rule, so the isRelocationTarget flag threaded
through TranslogConfig and buildTranslogTransferManager no longer fed
anything: remove the field, the constructor parameters, and the
now-redundant overload.

Document two edges instead of defending them: the dynamic-durability
window in getReplicationMode (a REQUEST->ASYNC flip can acknowledge
one operation against neither witness, which is what ASYNC durability
means), and why RemoteStoreFence.tla's single-register abstraction is
faithful - it leans on the recorded-ownership guard, and the
claim-twice window it cannot express is checked by FenceTakeover.tla.

Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 6e63b54

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for 6e63b54: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for 6e63b54: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

@Bukhtawar

Copy link
Copy Markdown
Contributor Author

Gradle-check failure triage — three failures across the last two runs, all reproduced locally as flaky:

Test Local repro Flaky tracker
ClusterDisruptionIT.testAckedIndexing passes with the exact CI seed (ECD0091FA940324C, same locale/tz/JVM args, JDK 25) and 3 random-seed iterations #14308
RemoteStoreStatsIT.testNonZeroPrimaryStatsOnNewlyCreatedIndexWithZeroDocs passes 3 iterations each with fencing forced on and off (-Dtests.remote_store.fencing=…) #14310 (also pre-fencing #10983)
SharedClusterSnapshotRestoreIT.testSnapshotFileFailureDuringSnapshot passes 3 iterations #15845

ClusterDisruptionIT and SharedClusterSnapshotRestoreIT run document-replication clusters, where fencing never engages (requires remote store + the off-by-default setting). The failing commits were comment/dead-code-only; the functional commits before them passed gradle-check. Re-triggering.

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for 6e63b54: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

@Bukhtawar
Bukhtawar marked this pull request as ready for review August 30, 2026 08:53
@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for 6e63b54: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for 6e63b54: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for 6e63b54: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

return true;
}
if (e.statusCode() != 409) {
return false;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://docs.aws.amazon.com/AmazonS3/latest/userguide/conditional-writes.html#conditional-error-response

If there's no current object version with the same name, or if the current object version is a delete marker, the operation fails with a 404 Not Found error.

Do we need to detect the 404 case? If I understand correctly, this is the case a partitioned writer will see once its successor is promoted and deletes the key the former primary was checking against.

Comment on lines +270 to +272
if (content.length > sizeBound) {
throw new IOException("[" + blobName + "] blob of size > [" + sizeBound + "] is too large for a versioned read");
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

default value of sizeBound is 5 MB . Do we need a smaller value here for this object fencing token ?

// when the translog construction downloads it in the snapshot V2 case), or destroys the remote
// translog lineage (the snapshot restore delete).
if (shardRouting.primary() && indexSettings.isRemoteTranslogStoreEnabled()) {
sealRemoteStoreFenceBeforeRestore();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit : rename to sealRemoteStoreFence since this is not only restore

IndexMetadata.INDEX_REMOTE_STORE_ENABLED_SETTING,
IndexMetadata.INDEX_REMOTE_SEGMENT_STORE_REPOSITORY_SETTING,
IndexMetadata.INDEX_REMOTE_TRANSLOG_REPOSITORY_SETTING,
IndexMetadata.INDEX_REMOTE_STORE_FENCING_ENABLED_SETTING,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one caveat for this feature is we can't enable it for remote migrating indices .

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants