This is an automated email from the ASF dual-hosted git repository.

tkhurana pushed a commit to branch PHOENIX-7562-feature-new
in repository https://gitbox.apache.org/repos/asf/phoenix.git


The following commit(s) were added to refs/heads/PHOENIX-7562-feature-new by 
this push:
     new 1f64927d19 PHOENIX-8025 Skip the consistent-failover replication log 
for PARALLEL HA groups (#2648)
1f64927d19 is described below

commit 1f64927d19e696c46a9aebec0781b74fb1d6a6ba
Author: tkhurana <[email protected]>
AuthorDate: Wed Oct 28 12:05:16 2026 -0700

    PHOENIX-8025 Skip the consistent-failover replication log for PARALLEL HA 
groups (#2648)
    
    A PARALLEL HA group does not need the replication log: the client writes 
both
    clusters itself. ReplicationLogGroup.get() now always returns a group whose
    Status tells the caller what to do: REPLICATING (live writer), SKIP (no
    replication log), or NOT_ACTIVE (FAILOVER group on a non-active cluster).
    
    - A PARALLEL group gets a cached NoOpReplicationLogGroup on either role, so 
it
      never builds a writer (no peer-FS files, rotation, mode flips or abort
      exposure), including at RS startup eager init, and later batches do not
      re-read the HA group store. The policy is checked before the role.
    - NOT_ACTIVE is a shared instance that is never cached, so the first get()
      after promotion builds the writer. Non-replicated writes use the shared 
NONE.
    - getOrCreate checks INSTANCES before computeIfAbsent.
    - IndexRegionObserver ships only when the group isReplicated(), throws
      split-brain on NOT_ACTIVE, and drops isParallelPolicy. preWALRestore skips
      any non-replicating group, which covers PARALLEL edits stamped before this
      change.
    
    Also close ReplicationLog when init() fails to create the first writer, so a
    failed init() no longer leaves an orphaned rotation thread or staged writer.
    
    Supersedes #2638; its IndexRegionObserver unit tests and PARALLEL IT case 
are
    carried over.
    
    Generated-by: Claude Code (Opus 5.5)
---
 .../coprocessor/PhoenixRegionServerEndpoint.java   |   8 +-
 .../phoenix/hbase/index/IndexRegionObserver.java   | 142 +++++-------
 .../apache/phoenix/replication/ReplicationLog.java |  10 +-
 .../phoenix/replication/ReplicationLogGroup.java   | 253 +++++++++++++++------
 .../apache/phoenix/jdbc/HAGroupStoreManagerIT.java |   4 +-
 .../replication/ReplicationLogGroupBaseIT.java     |   8 +-
 .../phoenix/replication/ReplicationLogGroupIT.java |   7 +-
 .../replication/ReplicationLogGroupParallelIT.java |  61 ++++-
 .../reader/StoreAndForwardFailoverIT.java          |   2 +-
 ...IndexRegionObserverReplicationLogGroupTest.java | 232 +++++++++++++++++++
 .../replication/ReplicationLogGroupTest.java       | 168 +++++++++++---
 11 files changed, 696 insertions(+), 199 deletions(-)

diff --git 
a/phoenix-core-server/src/main/java/org/apache/phoenix/coprocessor/PhoenixRegionServerEndpoint.java
 
b/phoenix-core-server/src/main/java/org/apache/phoenix/coprocessor/PhoenixRegionServerEndpoint.java
index c68ee31ddc..d271ec5fa0 100644
--- 
a/phoenix-core-server/src/main/java/org/apache/phoenix/coprocessor/PhoenixRegionServerEndpoint.java
+++ 
b/phoenix-core-server/src/main/java/org/apache/phoenix/coprocessor/PhoenixRegionServerEndpoint.java
@@ -325,10 +325,8 @@ public class PhoenixRegionServerEndpoint extends
                     + " Will be lazily initialized on first mutation.", 
haGroup, e);
                 }
               } else {
-                LOGGER.info(
-                  "Skipping eager ReplicationLogGroup init for HA group: {} on 
server {};"
-                    + " the writer will be created lazily if this cluster 
becomes active.",
-                  haGroup, serverName);
+                LOGGER.info("Skipping eager ReplicationLogGroup init for HA 
group: {} on server {};"
+                  + " the group will be created lazily on first mutation.", 
haGroup, serverName);
               }
               iterator.remove();
               LOGGER.info("Prewarmed HAGroupStoreClient: {} ({} remaining)", 
haGroup,
@@ -354,7 +352,7 @@ public class PhoenixRegionServerEndpoint extends
   /**
    * Eagerly initialize ReplicationLogGroup only on clusters whose local role 
is currently active
    * (ACTIVE or ACTIVE_TO_STANDBY). On STANDBY/OFFLINE/UNKNOWN clusters the 
group will be created
-   * lazily on first mutation if the role transitions.
+   * lazily on first mutation.
    */
   private boolean shouldEagerlyInit(HAGroupStoreManager manager, String 
haGroup) {
     if (serverName == null) {
diff --git 
a/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/IndexRegionObserver.java
 
b/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/IndexRegionObserver.java
index f80da44167..a11b5ec144 100644
--- 
a/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/IndexRegionObserver.java
+++ 
b/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/IndexRegionObserver.java
@@ -136,8 +136,6 @@ import org.apache.phoenix.index.PhoenixIndexBuilderHelper;
 import org.apache.phoenix.index.PhoenixIndexCodec;
 import org.apache.phoenix.index.PhoenixIndexMetaData;
 import org.apache.phoenix.jdbc.HAGroupStoreManager;
-import org.apache.phoenix.jdbc.HAGroupStoreRecord;
-import org.apache.phoenix.jdbc.HighAvailabilityPolicy;
 import org.apache.phoenix.query.KeyRange;
 import org.apache.phoenix.query.QueryConstants;
 import org.apache.phoenix.query.QueryServicesOptions;
@@ -505,7 +503,7 @@ public class IndexRegionObserver implements 
RegionCoprocessor, RegionObserver {
     // derived from it. Defaults to CLIENT_NON_HA (benign: no HA handling).
     private BatchOrigin origin = BatchOrigin.CLIENT_NON_HA;
     // HAGroup associated with the batch
-    private Optional<ReplicationLogGroup> logGroup = Optional.empty();
+    private ReplicationLogGroup logGroup = ReplicationLogGroup.NONE;
     // Per-(row, ts) groups folded from this replicated batch, computed once 
and shared by the
     // global-index path (prepareReplicatedIndexMutations) and the local-index 
path. Null until
     // first built; only populated on the standby replay path (isReplay).
@@ -697,6 +695,12 @@ public class IndexRegionObserver implements 
RegionCoprocessor, RegionObserver {
     this.dataTableName = dataTableName;
   }
 
+  @VisibleForTesting
+  IndexRegionObserver(String dataTableName, boolean replicateTable) {
+    this(dataTableName);
+    this.replicateTable = replicateTable;
+  }
+
   @Override
   public Optional<RegionObserver> getRegionObserver() {
     return Optional.of(this);
@@ -957,9 +961,9 @@ public class IndexRegionObserver implements 
RegionCoprocessor, RegionObserver {
         }
       }
 
-      // Resolve the replication writer for this batch (empty if none should 
be used). This also
-      // enforces the split-brain guard for non-PARALLEL live writes to a 
non-active cluster.
-      Optional<ReplicationLogGroup> logGroup =
+      // Resolve the replication log group for this batch. This also enforces 
the split-brain
+      // guard for non-PARALLEL live writes to a non-active cluster.
+      ReplicationLogGroup logGroup =
         getReplicationLogGroup(c.getEnvironment(), haGroupStoreManager, 
origin, haGroupName);
       preBatchMutateWithExceptions(c, miniBatchOp, logGroup, origin);
       return;
@@ -971,39 +975,32 @@ public class IndexRegionObserver implements 
RegionCoprocessor, RegionObserver {
   }
 
   /**
-   * Resolve the replication writer for this batch, or empty when none should 
be used. Returns empty
+   * Resolve the replication log group for this batch. Returns {@link 
ReplicationLogGroup#NONE}
    * unless this is a CLIENT_HA write on a replication-eligible cluster 
({@code replicateTable} =
    * feature on AND table eligible) — which covers the feature-off rollout 
window and non-eligible
-   * tables. For such a write it then answers the role/policy question: the 
writer when this cluster
-   * is active for the group, empty for a non-active PARALLEL cluster 
(legitimate double-write, skip
-   * re-ship), and throws for a non-active non-PARALLEL (split-brain) live 
write.
-   * @return the HA group writer, or empty if none should be used
+   * tables. For such a write the group answers the role/policy question: a 
writer
+   * ({@code REPLICATING}) when this cluster is active for a non-PARALLEL 
group, a no-op group
+   * ({@code SKIP}) for a PARALLEL group on either role (the client writes 
both clusters itself),
+   * and a non-active non-PARALLEL (split-brain) live write throws.
+   * @return the HA group's replication log group, never null
    */
-  private Optional<ReplicationLogGroup> 
getReplicationLogGroup(RegionCoprocessorEnvironment env,
+  @VisibleForTesting
+  ReplicationLogGroup getReplicationLogGroup(RegionCoprocessorEnvironment env,
     HAGroupStoreManager haGroupStoreManager, BatchOrigin origin, 
Optional<String> haGroupName)
     throws IOException {
     if (!replicateTable || origin != BatchOrigin.CLIENT_HA) {
-      return Optional.empty();
+      return ReplicationLogGroup.NONE;
     }
     // origin == CLIENT_HA guarantees the HA group name is present.
     String haGroup = haGroupName.get();
-    Optional<ReplicationLogGroup> logGroup = 
ReplicationLogGroup.get(env.getConfiguration(),
+    ReplicationLogGroup logGroup = 
ReplicationLogGroup.get(env.getConfiguration(),
       env.getServerName(), haGroup, haGroupStoreManager, abortable);
-    if (logGroup.isPresent()) {
+    if (logGroup.getStatus() != ReplicationLogGroup.Status.NOT_ACTIVE) {
       return logGroup;
     }
-    // get() returned empty: this cluster is not active for the HA group. Its 
meaning depends on
-    // the HA policy of the write that just arrived:
-    // - PARALLEL: the client deliberately double-writes to both clusters, so 
a live write on
-    // the standby is expected. Commit locally and skip re-ship (a non-active 
cluster has no
-    // writer) — the peer gets its copy from the client's other leg. Same as 
the replay path
-    // in getHAGroupFromWALKey.
-    // - FAILOVER: only the active cluster takes writes, so a live write here 
is a stray/stale
-    // sync-path write to a demoted cluster. Reject it rather than committing 
locally
-    // unreplicated (split brain).
-    if (isParallelPolicy(haGroupStoreManager, haGroup)) {
-      return Optional.empty();
-    }
+    // This cluster is not active for a non-PARALLEL HA group. Under FAILOVER 
only the active
+    // cluster takes writes, so this is a stray/stale sync-path write to a 
demoted cluster. Reject
+    // it rather than committing locally unreplicated (split brain).
     throw new IOException(
       String.format("HAGroup %s cannot accept a HA write on %s: this cluster 
is not active",
         haGroup, dataTableName));
@@ -1061,41 +1058,25 @@ public class IndexRegionObserver implements 
RegionCoprocessor, RegionObserver {
     return haGroupName.isPresent() ? BatchOrigin.CLIENT_HA : 
BatchOrigin.CLIENT_NON_HA;
   }
 
-  /**
-   * True when the HA group uses the PARALLEL policy, under which the client 
double-writes to both
-   * clusters. On a non-active cluster this distinguishes a legitimate 
PARALLEL standby write (which
-   * should commit locally and skip re-ship) from a stray FAILOVER write 
(split brain, to reject). A
-   * missing record is treated as not-PARALLEL so the caller keeps the 
conservative split-brain
-   * guard.
-   */
-  private boolean isParallelPolicy(HAGroupStoreManager haGroupStoreManager, 
String haGroupName)
-    throws IOException {
-    Optional<HAGroupStoreRecord> record =
-      haGroupStoreManager.getEffectiveHAGroupStoreRecord(haGroupName);
-    return record.isPresent()
-      && 
HighAvailabilityPolicy.PARALLEL.name().equalsIgnoreCase(record.get().getPolicy());
-  }
-
   /**
    * Get the HA group associated with the WAL key. A batch of mutations is 
recorded in a single WAL
    * edit.
-   * @return HA group if present or empty if missing
+   * @return the HA group's replication log group, or {@link 
ReplicationLogGroup#NONE} if the key
+   *         carries no HA group
    */
-  private Optional<ReplicationLogGroup> 
getHAGroupFromWALKey(RegionCoprocessorEnvironment env,
+  private ReplicationLogGroup 
getHAGroupFromWALKey(RegionCoprocessorEnvironment env,
     Map<String, byte[]> walKeyAttrs) throws IOException {
     byte[] haGroupName = 
walKeyAttrs.get(BaseScannerRegionObserverConstants.HA_GROUP_NAME_ATTRIB);
     if (haGroupName != null) {
-      // get() returns empty when this cluster is not active. On the replay 
path this means a
-      // demoted
-      // cluster is replaying its own formerly-ACTIVE edits after a crash: the 
peer is ACTIVE now,
-      // so
-      // this cluster must not start a replication writer or re-ship. 
Returning empty lets the
-      // caller
-      // skip the re-ship; HBase still replays the edits into the local region.
+      // get() returns a NOT_ACTIVE group when this cluster is not active. On 
the replay path this
+      // means a demoted cluster is replaying its own formerly-ACTIVE edits 
after a crash: the peer
+      // is ACTIVE now, so this cluster must not start a replication writer or 
re-ship. A PARALLEL
+      // group (including edits stamped before PARALLEL skipped the log) 
returns SKIP. Either way
+      // the caller skips the re-ship; HBase still replays the edits into the 
local region.
       return ReplicationLogGroup.get(env.getConfiguration(), 
env.getServerName(),
         Bytes.toString(haGroupName), abortable);
     }
-    return Optional.empty();
+    return ReplicationLogGroup.NONE;
   }
 
   @Override
@@ -1110,14 +1091,13 @@ public class IndexRegionObserver implements 
RegionCoprocessor, RegionObserver {
       return;
     }
     Map<String, byte[]> walKeyAttrs = getAttributeValuesFromWALKey(logKey);
-    Optional<ReplicationLogGroup> logGroup =
-      getHAGroupFromWALKey(ctx.getEnvironment(), walKeyAttrs);
-    if (!logGroup.isPresent()) {
+    ReplicationLogGroup logGroup = getHAGroupFromWALKey(ctx.getEnvironment(), 
walKeyAttrs);
+    if (!logGroup.isReplicated()) {
       return;
     }
     long start = EnvironmentEdgeManager.currentTimeMillis();
     try {
-      replicateEditOnWALRestore(logGroup.get(), logKey, walKeyAttrs, logEdit);
+      replicateEditOnWALRestore(logGroup, logKey, walKeyAttrs, logEdit);
     } finally {
       long duration = EnvironmentEdgeManager.currentTimeMillis() - start;
       metricSource.updatePreWALRestoreTime(dataTableName, duration);
@@ -1825,23 +1805,24 @@ public class IndexRegionObserver implements 
RegionCoprocessor, RegionObserver {
   }
 
   /**
-   * True when this batch must be replicated from this cluster: a resolved HA 
log group is present,
-   * which (per its sole resolution site) already means a CLIENT_HA write on a 
replication-eligible
-   * cluster that is active for the group. Gates the active-side pre-image 
capture — the pre-image
-   * exists only so the standby can regenerate its index, so capturing it on a 
non-replicated batch
-   * would be wasted work (and an unnecessary region scan on the local path) — 
and the WAL-key
-   * replication stamp. It intentionally does NOT gate on {@code 
ignoreSyncReplicationForTesting}:
-   * that flag suppresses only the synchronous ship (checked explicitly at the 
ship site) to
-   * simulate an RS crash after the WAL write but before the ship, leaving 
pre-image capture, the
-   * WAL stamp, and the replay path intact so crash-recovery re-ship is 
faithfully exercised.
+   * True when this batch must be replicated from this cluster: the resolved 
HA log group is
+   * replicating, which (per its sole resolution site) already means a 
CLIENT_HA write on a
+   * replication-eligible cluster that is active for a non-PARALLEL group. 
Gates the active-side
+   * pre-image capture — the pre-image exists only so the standby can 
regenerate its index, so
+   * capturing it on a non-replicated batch would be wasted work (and an 
unnecessary region scan on
+   * the local path) — and the WAL-key replication stamp. It intentionally 
does NOT gate on
+   * {@code ignoreSyncReplicationForTesting}: that flag suppresses only the 
synchronous ship
+   * (checked explicitly at the ship site) to simulate an RS crash after the 
WAL write but before
+   * the ship, leaving pre-image capture, the WAL stamp, and the replay path 
intact so
+   * crash-recovery re-ship is faithfully exercised.
    */
   private boolean isReplicatedBatch(BatchMutateContext context) {
-    // The log group is resolved (in preBatchMutate) only for a CLIENT_HA 
write on a
-    // replication-eligible cluster that is active for the group, and that 
resolution site is its
-    // sole assignment — so its presence alone is the full predicate for "ship 
this batch". The
-    // "present implies CLIENT_HA" half of that is asserted at context setup in
+    // A replicating log group is resolved (in preBatchMutate) only for a 
CLIENT_HA write on a
+    // replication-eligible cluster that is active for a non-PARALLEL group, 
and that resolution
+    // site is its sole assignment — so isReplicated() alone is the full 
predicate for "ship this
+    // batch". The "replicated implies CLIENT_HA" half of that is asserted at 
context setup in
     // preBatchMutateWithExceptions.
-    return context.logGroup.isPresent();
+    return context.logGroup.isReplicated();
   }
 
   /**
@@ -2723,19 +2704,18 @@ public class IndexRegionObserver implements 
RegionCoprocessor, RegionObserver {
   }
 
   public void 
preBatchMutateWithExceptions(ObserverContext<RegionCoprocessorEnvironment> c,
-    MiniBatchOperationInProgress<Mutation> miniBatchOp, 
Optional<ReplicationLogGroup> logGroup,
+    MiniBatchOperationInProgress<Mutation> miniBatchOp, ReplicationLogGroup 
logGroup,
     BatchOrigin origin) throws Throwable {
     PhoenixIndexMetaData indexMetaData = getPhoenixIndexMetaData(c, 
miniBatchOp);
     BatchMutateContext context = new 
BatchMutateContext(indexMetaData.getClientVersion());
     context.origin = origin;
     context.logGroup = logGroup;
-    // A resolved log group is produced (in preBatchMutate) only for a 
CLIENT_HA write on a
-    // replication-eligible cluster active for the group, so its presence must 
imply CLIENT_HA. This
-    // is the invariant isReplicatedBatch() relies on to key "ship this batch" 
off log-group
-    // presence
-    // alone; assert it here so a future resolution change can't silently 
break the ship predicate.
-    Preconditions.checkState(!logGroup.isPresent() || origin == 
BatchOrigin.CLIENT_HA,
-      "log group present but batch origin is %s, not CLIENT_HA", origin);
+    // A replicating log group is produced (in preBatchMutate) only for a 
CLIENT_HA write on a
+    // replication-eligible cluster active for the group, so it must imply 
CLIENT_HA. This is the
+    // invariant isReplicatedBatch() relies on to key "ship this batch" off 
isReplicated() alone;
+    // assert it here so a future resolution change can't silently break the 
ship predicate.
+    Preconditions.checkState(!logGroup.isReplicated() || origin == 
BatchOrigin.CLIENT_HA,
+      "log group replicating but batch origin is %s, not CLIENT_HA", origin);
     setBatchMutateContext(c, context);
     identifyIndexMaintainerTypes(indexMetaData, context);
     identifyMutationTypes(miniBatchOp, context);
@@ -3006,8 +2986,8 @@ public class IndexRegionObserver implements 
RegionCoprocessor, RegionObserver {
    * keys (it is in {@code ReplicationLogGroup.REPLICATION_ATTR_KEYS}), so it 
needs no separate
    * stamp. Gated on {@link #isReplicatedBatch} — the same predicate that 
gates pre-image capture —
    * so the WAL carries this stamp exactly when it carries the pre-images the 
re-ship needs. On a
-   * standby replay the log group is absent, so the standby's WAL never 
carries replication metadata
-   * it could never re-ship.
+   * standby replay the log group is NONE (not replicated), so the standby's 
WAL never carries
+   * replication metadata it could never re-ship.
    */
   private void appendReplicationAttributesToWALKey(WALKey key,
     IndexRegionObserver.BatchMutateContext context) {
@@ -3095,7 +3075,7 @@ public class IndexRegionObserver implements 
RegionCoprocessor, RegionObserver {
         // ignoreSyncReplicationForTesting suppresses only this synchronous 
ship, simulating an RS
         // crash after the WAL write but before the ship; the WAL-restore path 
re-ships on replay.
         if (isReplicatedBatch(context) && !ignoreSyncReplicationForTesting) {
-          replicateMutations(context.logGroup.get(), miniBatchOp, context);
+          replicateMutations(context.logGroup, miniBatchOp, context);
         }
         FutureUtils.get(postIndexFuture);
       }
diff --git 
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/ReplicationLog.java
 
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/ReplicationLog.java
index 25fd8450e4..855f6f2c02 100644
--- 
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/ReplicationLog.java
+++ 
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/ReplicationLog.java
@@ -139,8 +139,16 @@ public class ReplicationLog {
 
   /** Initialize the writer. */
   public void init() throws IOException {
+    // Start rotation first so the first tick is aligned to the round in which 
init began, even if
+    // creating the writer crosses a round boundary.
     startRotationExecutor();
-    currentWriter = createNewWriter();
+    try {
+      currentWriter = createNewWriter();
+    } catch (IOException | RuntimeException e) {
+      // Nothing owns a log whose init failed: stop rotation and close any 
writer a tick staged.
+      close(false);
+      throw e;
+    }
     generation = currentWriter.getGeneration();
   }
 
diff --git 
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/ReplicationLogGroup.java
 
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/ReplicationLogGroup.java
index b1751f11c5..a738066e72 100644
--- 
a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/ReplicationLogGroup.java
+++ 
b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/ReplicationLogGroup.java
@@ -75,6 +75,7 @@ import org.apache.phoenix.jdbc.HAGroupStateListener;
 import org.apache.phoenix.jdbc.HAGroupStoreManager;
 import org.apache.phoenix.jdbc.HAGroupStoreRecord;
 import org.apache.phoenix.jdbc.HAGroupStoreRecord.HAGroupState;
+import org.apache.phoenix.jdbc.HighAvailabilityPolicy;
 import org.apache.phoenix.replication.metrics.MetricsReplicationLogGroupSource;
 import 
org.apache.phoenix.replication.metrics.MetricsReplicationLogGroupSourceFactory;
 import org.slf4j.Logger;
@@ -240,6 +241,25 @@ public class ReplicationLogGroup {
   protected static final ConcurrentHashMap<String, ReplicationLogGroup> 
INSTANCES =
     new ConcurrentHashMap<>();
 
+  /**
+   * What a write to this group should do with the replication log.
+   */
+  public enum Status {
+    /** A live writer: ship the batch through the replication log. */
+    REPLICATING,
+    /** No replication log for this write (PARALLEL group, or not a replicated 
write). */
+    SKIP,
+    /** FAILOVER group whose local role is not active: this cluster must not 
take the write. */
+    NOT_ACTIVE
+  }
+
+  /** Shared group for writes that are not replicated (non-CLIENT_HA origin, 
ineligible table). */
+  public static final ReplicationLogGroup NONE = new 
NoOpReplicationLogGroup(Status.SKIP);
+
+  /** Shared group returned for a FAILOVER group on a non-active cluster. 
Never cached. */
+  private static final ReplicationLogGroup NOT_ACTIVE =
+    new NoOpReplicationLogGroup(Status.NOT_ACTIVE);
+
   /**
    * Canonical, DNS-independent identity of a RegionServer within this JVM's 
{@link #INSTANCES}
    * cache. Uses {@code (port, startcode)} and deliberately EXCLUDES the 
hostname.
@@ -470,30 +490,30 @@ public class ReplicationLogGroup {
   protected LogEventHandler eventHandler;
 
   /**
-   * Get or create a ReplicationLogGroup instance for the given HA Group. 
Returns empty when this
-   * cluster is not active for the HA group (see {@link #getOrCreate}).
+   * Get or create a ReplicationLogGroup instance for the given HA Group. 
Never null; check
+   * {@link #getStatus()} (see {@link #getOrCreate}).
    * @param conf        Configuration object
    * @param serverName  The server name
    * @param haGroupName The HA Group name
-   * @return ReplicationLogGroup instance, or empty if this cluster is not 
active
+   * @return ReplicationLogGroup instance
    * @throws IOException if initialization fails
    */
-  public static Optional<ReplicationLogGroup> get(Configuration conf, 
ServerName serverName,
+  public static ReplicationLogGroup get(Configuration conf, ServerName 
serverName,
     String haGroupName) throws IOException {
     return get(conf, serverName, haGroupName, (Abortable) null);
   }
 
   /**
-   * Get or create a ReplicationLogGroup instance for the given HA Group. 
Returns empty when this
-   * cluster is not active for the HA group (see {@link #getOrCreate}).
+   * Get or create a ReplicationLogGroup instance for the given HA Group. 
Never null; check
+   * {@link #getStatus()} (see {@link #getOrCreate}).
    * @param conf        Configuration object
    * @param serverName  The server name
    * @param haGroupName The HA Group name
    * @param abortable   Abortable to invoke on fatal errors (typically 
RegionServerServices)
-   * @return ReplicationLogGroup instance, or empty if this cluster is not 
active
+   * @return ReplicationLogGroup instance
    * @throws IOException if initialization fails
    */
-  public static Optional<ReplicationLogGroup> get(Configuration conf, 
ServerName serverName,
+  public static ReplicationLogGroup get(Configuration conf, ServerName 
serverName,
     String haGroupName, Abortable abortable) throws IOException {
     return getOrCreate(conf, serverName, haGroupName, 
HAGroupStoreManager.getInstance(conf),
       abortable);
@@ -505,83 +525,98 @@ public class ReplicationLogGroup {
    * @param serverName          The server name
    * @param haGroupName         The HA Group name
    * @param haGroupStoreManager HA Group Store Manager instance
-   * @return ReplicationLogGroup instance, or empty if this cluster is not 
active
+   * @return ReplicationLogGroup instance
    * @throws IOException if initialization fails
    */
-  public static Optional<ReplicationLogGroup> get(Configuration conf, 
ServerName serverName,
+  public static ReplicationLogGroup get(Configuration conf, ServerName 
serverName,
     String haGroupName, HAGroupStoreManager haGroupStoreManager) throws 
IOException {
     return getOrCreate(conf, serverName, haGroupName, haGroupStoreManager, 
null);
   }
 
   /**
-   * Get or create a ReplicationLogGroup instance for the given HA Group. 
Returns empty when this
-   * cluster is not active for the HA group (see {@link #getOrCreate}). Used 
by callers that already
-   * hold the store manager and want fatal errors to abort via the supplied 
{@link Abortable}.
+   * Get or create a ReplicationLogGroup instance for the given HA Group. 
Never null; check
+   * {@link #getStatus()} (see {@link #getOrCreate}). Used by callers that 
already hold the store
+   * manager and want fatal errors to abort via the supplied {@link Abortable}.
    * @param conf                Configuration object
    * @param serverName          The server name
    * @param haGroupName         The HA Group name
    * @param haGroupStoreManager HA Group Store Manager instance
    * @param abortable           Abortable to invoke on fatal errors (typically 
RegionServerServices)
-   * @return ReplicationLogGroup instance, or empty if this cluster is not 
active
+   * @return ReplicationLogGroup instance
    * @throws IOException if initialization fails
    */
-  public static Optional<ReplicationLogGroup> get(Configuration conf, 
ServerName serverName,
+  public static ReplicationLogGroup get(Configuration conf, ServerName 
serverName,
     String haGroupName, HAGroupStoreManager haGroupStoreManager, Abortable 
abortable)
     throws IOException {
     return getOrCreate(conf, serverName, haGroupName, haGroupStoreManager, 
abortable);
   }
 
   /**
-   * Get an existing, or create and initialize a new, ReplicationLogGroup — 
but only when this
-   * cluster is active for the HA group. A replication log group is a WRITER; 
it must exist only
-   * where this cluster is the active side. When the local role is not active 
this returns
-   * {@link Optional#empty()} and does NOT cache, so a later call re-checks 
the (now-active) role
-   * once this cluster is promoted.
+   * Get an existing, or create and initialize a new, ReplicationLogGroup. A 
PARALLEL group gets a
+   * cached {@link NoOpReplicationLogGroup} ({@link Status#SKIP}) on either 
role: the client writes
+   * both clusters itself. Otherwise a replication log group is a WRITER and 
must exist only where
+   * this cluster is the active side. When the local role is not active this 
returns the shared
+   * {@link Status#NOT_ACTIVE} group and does NOT cache, so a later call 
re-checks the (now-active)
+   * role once this cluster is promoted.
    */
-  private static Optional<ReplicationLogGroup> getOrCreate(Configuration conf,
-    ServerName serverName, String haGroupName, HAGroupStoreManager 
haGroupStoreManager,
-    Abortable abortable) throws IOException {
+  private static ReplicationLogGroup getOrCreate(Configuration conf, 
ServerName serverName,
+    String haGroupName, HAGroupStoreManager haGroupStoreManager, Abortable 
abortable)
+    throws IOException {
+    String key = instanceKey(serverName, haGroupName);
+    // Fast path: computeIfAbsent may lock the bucket even when the key is 
present.
+    ReplicationLogGroup cached = INSTANCES.get(key);
+    if (cached != null) {
+      return cached;
+    }
     try {
-      ReplicationLogGroup group =
-        INSTANCES.computeIfAbsent(instanceKey(serverName, haGroupName), k -> {
-          try {
-            // Role check BEFORE constructing any writer resource. Only 
mutations carrying
-            // HA_GROUP_NAME reach get() (the standby replay-apply path does 
not annotate that
-            // attribute), via either the live write path or preWALRestore 
recovery. On a non-active
-            // role we must not start a writer: on the live path it would be a 
stray/split-brain
-            // sync-path write, and on the replay path it is a demoted cluster 
replaying its own
-            // formerly-ACTIVE edits after a crash while the peer is now 
ACTIVE. Returning null
-            // keeps
-            // computeIfAbsent from caching, so the first call after this 
cluster is promoted
-            // re-runs
-            // the check against the now-active role. The mutation-blocked 
role (ACTIVE_TO_STANDBY,
-            // the ACTIVE_IN_SYNC_TO_STANDBY cutover gate) is active and 
deliberately allowed: it is
-            // reached from preWALRestore recovery on an RS restart during 
cutover, and the writer
-            // starts in SYNC with rotation suspended via failoverPending (see 
init()).
-            Optional<HAGroupStoreRecord> haRecord =
-              haGroupStoreManager.getEffectiveHAGroupStoreRecord(haGroupName);
-            if (!haRecord.isPresent()) {
-              throw new UncheckedIOException(new IOException(
-                String.format("HAGroup %s got an empty group store record", 
haGroupName)));
-            }
-            ClusterRoleRecord.ClusterRole role = 
haRecord.get().getClusterRole();
-            if (!role.isActive()) {
-              LOG.info(
-                "Not creating a ReplicationLogGroup for HA Group {}: local 
role {} is not active"
-                  + " (state {})",
-                haGroupName, role, haRecord.get().getHAGroupState());
-              return null;
-            }
-            ReplicationLogGroup created = new ReplicationLogGroup(conf, 
serverName, haGroupName,
-              haGroupStoreManager, abortable, haRecord.get());
-            created.init();
-            return created;
-          } catch (IOException e) {
-            LOG.error("Failed to create ReplicationLogGroup for HA Group: {}", 
haGroupName, e);
-            throw new UncheckedIOException(e);
+      ReplicationLogGroup group = INSTANCES.computeIfAbsent(key, k -> {
+        try {
+          // Policy, then role, BEFORE constructing any writer resource. Only 
mutations carrying
+          // HA_GROUP_NAME reach get() (the standby replay-apply path does not 
annotate that
+          // attribute), via either the live write path or preWALRestore 
recovery. A PARALLEL group
+          // never uses the replication log, so it gets a cached no-op group 
whatever the role. On a
+          // non-active role we must not start a writer: on the live path it 
would be a
+          // stray/split-brain sync-path write, and on the replay path it is a 
demoted cluster
+          // replaying its own formerly-ACTIVE edits after a crash while the 
peer is now ACTIVE.
+          // Returning null keeps computeIfAbsent from caching, so the first 
call after this
+          // cluster is promoted re-runs the check against the now-active 
role. The
+          // mutation-blocked role (ACTIVE_TO_STANDBY, the 
ACTIVE_IN_SYNC_TO_STANDBY cutover gate)
+          // is active and deliberately allowed: it is reached from 
preWALRestore recovery on an RS
+          // restart during cutover, and the writer starts in SYNC with 
rotation suspended via
+          // failoverPending (see init()).
+          Optional<HAGroupStoreRecord> haRecord =
+            haGroupStoreManager.getEffectiveHAGroupStoreRecord(haGroupName);
+          if (!haRecord.isPresent()) {
+            throw new UncheckedIOException(new IOException(
+              String.format("HAGroup %s got an empty group store record", 
haGroupName)));
           }
-        });
-      return Optional.ofNullable(group);
+          if 
(HighAvailabilityPolicy.PARALLEL.name().equalsIgnoreCase(haRecord.get().getPolicy()))
 {
+            LOG.info("HA Group {} uses the PARALLEL policy; not creating a 
replication log writer",
+              haGroupName);
+            return new NoOpReplicationLogGroup(serverName, haGroupName);
+          }
+          ClusterRoleRecord.ClusterRole role = haRecord.get().getClusterRole();
+          if (!role.isActive()) {
+            LOG.info(
+              "Not creating a ReplicationLogGroup for HA Group {}: local role 
{} is not active"
+                + " (state {})",
+              haGroupName, role, haRecord.get().getHAGroupState());
+            // Return null, not NOT_ACTIVE: computeIfAbsent does not cache a 
null, so the next
+            // get() re-reads the store and builds the writer once this 
cluster is promoted. A
+            // cached NOT_ACTIVE would stick, because policy and role are only 
read on a miss.
+            return null;
+          }
+          ReplicationLogGroup created = new ReplicationLogGroup(conf, 
serverName, haGroupName,
+            haGroupStoreManager, abortable, haRecord.get());
+          created.init();
+          return created;
+        } catch (IOException e) {
+          LOG.error("Failed to create ReplicationLogGroup for HA Group: {}", 
haGroupName, e);
+          throw new UncheckedIOException(e);
+        }
+      });
+      // null = not active and deliberately left uncached (see the lambda).
+      return group != null ? group : NOT_ACTIVE;
     } catch (UncheckedIOException e) {
       throw e.getCause();
     }
@@ -657,6 +692,21 @@ public class ReplicationLogGroup {
     this.mode = new AtomicReference<>(INIT);
   }
 
+  /**
+   * Light constructor for {@link NoOpReplicationLogGroup}: no conf clone, 
peer conf or metrics
+   * source, since a no-op group never writes.
+   */
+  private ReplicationLogGroup(ServerName serverName, String haGroupName) {
+    this.conf = null;
+    this.peerConf = null;
+    this.serverName = serverName;
+    this.haGroupName = haGroupName;
+    this.haGroupStoreManager = null;
+    this.abortable = null;
+    this.metrics = null;
+    this.mode = new AtomicReference<>(INIT);
+  }
+
   /**
    * Build the peer (standby) FS configuration: a clone of {@code base} with 
the given overrides
    * (prefix already stripped) applied on top. The applied override keys are 
logged so an operator
@@ -717,8 +767,8 @@ public class ReplicationLogGroup {
     // Initialize the disruptor so that we start processing events
     initializeDisruptor();
     // Register all LOCAL state listeners that react to this cluster's role 
changes (demotion,
-    // cutover suspend/resume, and mode flips). init() itself fails fast on a 
non-active role, so a
-    // group only ever exists while active and these listeners drive it from 
there.
+    // cutover suspend/resume, and mode flips). getOrCreate() builds a writer 
only on an active
+    // role, so these listeners drive it from there.
     subscribeToStateChanges();
     // Restart-in-cutover: when init() runs (via preWALRestore recovery) while 
the persisted state
     // is already ACTIVE_IN_SYNC_TO_STANDBY, the cutover listener won't fire — 
the state was read,
@@ -830,10 +880,10 @@ public class ReplicationLogGroup {
   }
 
   /**
-   * Initialize the replication mode based on the HAGroupStore state. {@link 
#init()} fails fast on
-   * a non-active role, so this is only reached for active states: 
ACTIVE_IN_SYNC and the
-   * ACTIVE_IN_SYNC_TO_STANDBY cutover gate both start SYNC (the gate is 
reached via preWALRestore
-   * recovery on a restart-in-cutover); every other active state 
(ACTIVE_NOT_IN_SYNC,
+   * Initialize the replication mode based on the HAGroupStore state. {@link 
#getOrCreate} builds a
+   * writer only on an active role, so this is only reached for active states: 
ACTIVE_IN_SYNC and
+   * the ACTIVE_IN_SYNC_TO_STANDBY cutover gate both start SYNC (the gate is 
reached via
+   * preWALRestore recovery on a restart-in-cutover); every other active state 
(ACTIVE_NOT_IN_SYNC,
    * ACTIVE_WITH_OFFLINE_PEER, ACTIVE_NOT_IN_SYNC_TO_STANDBY, ...) starts 
STORE_AND_FORWARD.
    */
   protected void initializeReplicationMode(HAGroupStoreRecord record) throws 
IOException {
@@ -1045,6 +1095,16 @@ public class ReplicationLogGroup {
     }
   }
 
+  /** What a write to this group should do with the replication log. */
+  public Status getStatus() {
+    return Status.REPLICATING;
+  }
+
+  /** True when writes to this group must be shipped through the replication 
log. */
+  public boolean isReplicated() {
+    return getStatus() == Status.REPLICATING;
+  }
+
   /**
    * Publish a non-blocking mode-check marker so an idle consumer applies a 
mode switch promptly.
    * Uses {@code tryNext()} like {@link #publishSwapEvent()}: when the ring 
buffer is full, the
@@ -1265,6 +1325,63 @@ public class ReplicationLogGroup {
     return getHAGroupName();
   }
 
+  /**
+   * A group with no replication log writer. A per-group instance ({@link 
Status#SKIP}) is cached
+   * for a PARALLEL HA group on either role, so its writes never re-read the 
store. The shared
+   * {@link #NONE} and {@link #NOT_ACTIVE} instances have no HA group and are 
never cached. No
+   * disruptor, forwarder, shard managers or state subscriptions are created.
+   */
+  static final class NoOpReplicationLogGroup extends ReplicationLogGroup {
+    private final Status status;
+
+    /** Per-group PARALLEL instance, cached in {@link #INSTANCES}. */
+    NoOpReplicationLogGroup(ServerName serverName, String haGroupName) {
+      super(serverName, haGroupName);
+      this.status = Status.SKIP;
+    }
+
+    /** Shared instance with no server or HA group. */
+    private NoOpReplicationLogGroup(Status status) {
+      super(null, "<" + status + ">");
+      this.status = status;
+    }
+
+    @Override
+    public Status getStatus() {
+      return status;
+    }
+
+    @Override
+    protected void init() {
+    }
+
+    @Override
+    public void append(String tableName, long commitId, Mutation mutation) {
+      throw new IllegalStateException("HAGroup " + this + " has no replication 
log writer");
+    }
+
+    @Override
+    public void append(String tableName, long commitId, List<Cell> cells,
+      Map<String, byte[]> attributes) {
+      throw new IllegalStateException("HAGroup " + this + " has no replication 
log writer");
+    }
+
+    @Override
+    public void sync() {
+      throw new IllegalStateException("HAGroup " + this + " has no replication 
log writer");
+    }
+
+    @Override
+    public void close() {
+      // The shared instances are never cached and must stay usable.
+      if (serverName == null || !closed.compareAndSet(false, true)) {
+        return;
+      }
+      INSTANCES.remove(instanceKey(serverName, haGroupName), this);
+      LOG.info("HAGroup {} closed (no replication log writer)", this);
+    }
+  }
+
   /**
    * Creates the top level directory on the cluster determined by the URI
    * @param uri        HDFS uri
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/jdbc/HAGroupStoreManagerIT.java 
b/phoenix-core/src/it/java/org/apache/phoenix/jdbc/HAGroupStoreManagerIT.java
index cb9f790d51..85d29ed872 100644
--- 
a/phoenix-core/src/it/java/org/apache/phoenix/jdbc/HAGroupStoreManagerIT.java
+++ 
b/phoenix-core/src/it/java/org/apache/phoenix/jdbc/HAGroupStoreManagerIT.java
@@ -759,8 +759,8 @@ public class HAGroupStoreManagerIT extends HABaseIT {
       // Start the forwarder only now; it finds no backlog and promotes the 
group back to
       // ACTIVE_IN_SYNC.
       HRegionServer regionServer = 
CLUSTERS.getHBaseCluster1().getHBaseCluster().getRegionServer(0);
-      replicationLogGroup = ReplicationLogGroup
-        .get(cluster1Conf, regionServer.getServerName(), haGroupName, 
cluster1HAManager).get();
+      replicationLogGroup = ReplicationLogGroup.get(cluster1Conf, 
regionServer.getServerName(),
+        haGroupName, cluster1HAManager);
       waitForHAGroupState(cluster1HAManager, haGroupName,
         HAGroupStoreRecord.HAGroupState.ACTIVE_IN_SYNC);
       waitForHAGroupState(cluster2HAManager, haGroupName, 
HAGroupStoreRecord.HAGroupState.STANDBY);
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/replication/ReplicationLogGroupBaseIT.java
 
b/phoenix-core/src/it/java/org/apache/phoenix/replication/ReplicationLogGroupBaseIT.java
index e00a046d0a..c363e4c1e9 100644
--- 
a/phoenix-core/src/it/java/org/apache/phoenix/replication/ReplicationLogGroupBaseIT.java
+++ 
b/phoenix-core/src/it/java/org/apache/phoenix/replication/ReplicationLogGroupBaseIT.java
@@ -140,8 +140,12 @@ public abstract class ReplicationLogGroupBaseIT extends 
HABaseIT {
 
   private ReplicationLogGroup getReplicationLogGroup() throws IOException {
     HRegionServer rs = 
CLUSTERS.getHBaseCluster1().getHBaseCluster().getRegionServer(0);
-    return ReplicationLogGroup.get(conf1, rs.getServerName(), haGroupName)
-      .orElseThrow(() -> new IOException("No active ReplicationLogGroup for " 
+ haGroupName));
+    // A PARALLEL group returns its no-op SKIP group: there is no writer to 
create.
+    ReplicationLogGroup group = ReplicationLogGroup.get(conf1, 
rs.getServerName(), haGroupName);
+    if (group.getStatus() == ReplicationLogGroup.Status.NOT_ACTIVE) {
+      throw new IOException("No active ReplicationLogGroup for " + 
haGroupName);
+    }
+    return group;
   }
 
   protected Map<String, List<Mutation>> groupLogsByTable() throws Exception {
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/replication/ReplicationLogGroupIT.java
 
b/phoenix-core/src/it/java/org/apache/phoenix/replication/ReplicationLogGroupIT.java
index b72634458d..727af83a00 100644
--- 
a/phoenix-core/src/it/java/org/apache/phoenix/replication/ReplicationLogGroupIT.java
+++ 
b/phoenix-core/src/it/java/org/apache/phoenix/replication/ReplicationLogGroupIT.java
@@ -1687,9 +1687,10 @@ public class ReplicationLogGroupIT extends 
ReplicationLogGroupBaseIT {
     assertTrue("Writer must be closed after demotion to STANDBY", 
logGroup.isClosed());
 
     // The role gate must now refuse to re-create a writer on the demoted 
(STANDBY) cluster: get()
-    // returns empty rather than caching a writer for a non-active role.
-    assertFalse("get() should return empty on a STANDBY cluster",
-      ReplicationLogGroup.get(conf1, sn, haGroupName).isPresent());
+    // returns NOT_ACTIVE rather than caching a writer for a non-active role.
+    assertEquals("get() should return NOT_ACTIVE on a STANDBY cluster",
+      ReplicationLogGroup.Status.NOT_ACTIVE,
+      ReplicationLogGroup.get(conf1, sn, haGroupName).getStatus());
   }
 
   /**
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/replication/ReplicationLogGroupParallelIT.java
 
b/phoenix-core/src/it/java/org/apache/phoenix/replication/ReplicationLogGroupParallelIT.java
index ae3b4047bf..14edc2ec0c 100644
--- 
a/phoenix-core/src/it/java/org/apache/phoenix/replication/ReplicationLogGroupParallelIT.java
+++ 
b/phoenix-core/src/it/java/org/apache/phoenix/replication/ReplicationLogGroupParallelIT.java
@@ -19,13 +19,19 @@ package org.apache.phoenix.replication;
 
 import static org.apache.phoenix.query.BaseTest.generateUniqueName;
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 
 import java.sql.Connection;
 import java.sql.DriverManager;
 import java.sql.PreparedStatement;
 import java.sql.ResultSet;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.fs.Path;
+import org.apache.hadoop.hbase.util.JVMClusterUtil.RegionServerThread;
 import org.apache.phoenix.end2end.NeedsOwnMiniClusterTest;
+import org.apache.phoenix.jdbc.HAGroupStoreManager;
+import org.apache.phoenix.jdbc.HAGroupStoreRecord;
 import org.apache.phoenix.jdbc.HighAvailabilityPolicy;
 import org.apache.phoenix.jdbc.ParallelPhoenixConnection;
 import org.junit.BeforeClass;
@@ -35,11 +41,11 @@ import org.junit.experimental.categories.Category;
 /**
  * PARALLEL-policy coverage for the write-path role/policy branch in
  * {@code IndexRegionObserver.getReplicationLogGroup}. Under PARALLEL the 
client double-writes to
- * both clusters via a {@link ParallelPhoenixConnection}; the leg that lands 
on the non-active
- * cluster must commit locally and skip re-ship (the {@code isParallelPolicy} 
branch returns empty)
- * rather than reject the write as split-brain — which is the FAILOVER 
behavior covered by
- * {@link ReplicationLogGroupIT}. Both clusters run with the replication 
feature enabled, so the
- * standby actually reaches this branch.
+ * both clusters via a {@link ParallelPhoenixConnection}, so each leg commits 
locally through the
+ * group's no-op {@code SKIP} log group: the non-active leg is not rejected as 
split-brain (the
+ * FAILOVER behavior covered by {@link ReplicationLogGroupIT}) and the active 
leg is not shipped to
+ * the peer. Both clusters run with the replication feature enabled, so both 
legs actually reach
+ * this branch.
  */
 @Category(NeedsOwnMiniClusterTest.class)
 public class ReplicationLogGroupParallelIT extends ReplicationLogGroupBaseIT {
@@ -66,8 +72,7 @@ public class ReplicationLogGroupParallelIT extends 
ReplicationLogGroupBaseIT {
     CLUSTERS.createTableOnClusterPair(haGroup, tableName, false);
 
     // PARALLEL policy => a ParallelPhoenixConnection that writes to both 
clusters. The leg landing
-    // on the non-active cluster must commit locally (isParallelPolicy 
branch), not throw
-    // split-brain.
+    // on the non-active cluster must commit locally (SKIP group), not throw 
split-brain.
     try (Connection conn = 
DriverManager.getConnection(CLUSTERS.getJdbcHAUrl(), clientProps)) {
       assertTrue("expected a ParallelPhoenixConnection under PARALLEL policy",
         conn instanceof ParallelPhoenixConnection);
@@ -86,6 +91,48 @@ public class ReplicationLogGroupParallelIT extends 
ReplicationLogGroupBaseIT {
     assertRowCount(CLUSTERS.getCluster2Connection(haGroup), tableName, 
rowCount);
   }
 
+  @Test
+  public void testParallelWriteOnActiveClusterIsNotShippedToReplicationLog() 
throws Exception {
+    final String tableName = "T_" + generateUniqueName();
+    final int rowCount = 10;
+    CLUSTERS.createTableOnClusterPair(haGroup, tableName, false);
+
+    try (Connection conn = 
DriverManager.getConnection(CLUSTERS.getJdbcHAUrl(), clientProps)) {
+      assertTrue("expected a ParallelPhoenixConnection under PARALLEL policy",
+        conn instanceof ParallelPhoenixConnection);
+      PreparedStatement stmt = conn.prepareStatement("upsert into " + 
tableName + " values (?, ?)");
+      for (int i = 0; i < rowCount; i++) {
+        stmt.setInt(1, i);
+        stmt.setInt(2, i);
+        stmt.executeUpdate();
+      }
+      conn.commit();
+    }
+    assertRowCount(CLUSTERS.getCluster1Connection(haGroup), tableName, 
rowCount);
+    assertRowCount(CLUSTERS.getCluster2Connection(haGroup), tableName, 
rowCount);
+
+    // Every RS on the active cluster holds the PARALLEL no-op group, never a 
writer.
+    for (RegionServerThread rst : CLUSTERS.getHBaseCluster1().getHBaseCluster()
+      .getRegionServerThreads()) {
+      ReplicationLogGroup group =
+        ReplicationLogGroup.get(conf1, rst.getRegionServer().getServerName(), 
haGroupName);
+      assertEquals(ReplicationLogGroup.Status.SKIP, group.getStatus());
+    }
+    // No writer was ever built for this group: an in-sync writer creates both 
its local fallback
+    // (out) and peer (in) dirs, so neither may exist.
+    HAGroupStoreRecord record =
+      
HAGroupStoreManager.getInstance(conf1).getHAGroupStoreRecord(haGroupName).get();
+    assertNoLogDir(conf1, record.getHdfsUrl(), 
ReplicationLogGroup.FALLBACK_DIR);
+    assertNoLogDir(conf2, record.getPeerHdfsUrl(), 
ReplicationLogGroup.STANDBY_DIR);
+  }
+
+  private void assertNoLogDir(Configuration conf, String hdfsUrl, String 
logDirName)
+    throws Exception {
+    Path dir = new Path(new Path(hdfsUrl, haGroupName), logDirName);
+    assertFalse("PARALLEL group must not create replication log dir " + dir,
+      dir.getFileSystem(conf).exists(dir));
+  }
+
   private static void assertRowCount(Connection conn, String tableName, int 
expected)
     throws Exception {
     try (Connection c = conn;
diff --git 
a/phoenix-core/src/it/java/org/apache/phoenix/replication/reader/StoreAndForwardFailoverIT.java
 
b/phoenix-core/src/it/java/org/apache/phoenix/replication/reader/StoreAndForwardFailoverIT.java
index 1e45d29ae0..b03a018f6d 100644
--- 
a/phoenix-core/src/it/java/org/apache/phoenix/replication/reader/StoreAndForwardFailoverIT.java
+++ 
b/phoenix-core/src/it/java/org/apache/phoenix/replication/reader/StoreAndForwardFailoverIT.java
@@ -127,7 +127,7 @@ public class StoreAndForwardFailoverIT extends HABaseIT {
     // The live Cluster1 writer (RS0). Same singleton the RS write path uses, 
so flipping its mode
     // affects real writes. Cluster1 is ACTIVE_IN_SYNC => this starts in SYNC 
mode.
     HRegionServer rs = 
CLUSTERS.getHBaseCluster1().getHBaseCluster().getRegionServer(0);
-    logGroup = ReplicationLogGroup.get(conf1, rs.getServerName(), 
haGroupName).get();
+    logGroup = ReplicationLogGroup.get(conf1, rs.getServerName(), haGroupName);
 
     // Replicated table (REPLICATION_SCOPE=1 + LOCAL INDEX) on BOTH clusters.
     tableName = "T_" + generateUniqueName();
diff --git 
a/phoenix-core/src/test/java/org/apache/phoenix/hbase/index/IndexRegionObserverReplicationLogGroupTest.java
 
b/phoenix-core/src/test/java/org/apache/phoenix/hbase/index/IndexRegionObserverReplicationLogGroupTest.java
new file mode 100644
index 0000000000..b816303976
--- /dev/null
+++ 
b/phoenix-core/src/test/java/org/apache/phoenix/hbase/index/IndexRegionObserverReplicationLogGroupTest.java
@@ -0,0 +1,232 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.phoenix.hbase.index;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Optional;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.fs.Path;
+import org.apache.hadoop.hbase.HBaseConfiguration;
+import org.apache.hadoop.hbase.ServerName;
+import org.apache.hadoop.hbase.coprocessor.ObserverContext;
+import org.apache.hadoop.hbase.coprocessor.RegionCoprocessorEnvironment;
+import org.apache.hadoop.hbase.util.Bytes;
+import org.apache.hadoop.hbase.wal.WALEdit;
+import org.apache.hadoop.hbase.wal.WALKey;
+import org.apache.phoenix.coprocessorclient.BaseScannerRegionObserverConstants;
+import org.apache.phoenix.hbase.index.IndexRegionObserver.BatchOrigin;
+import org.apache.phoenix.jdbc.ClusterType;
+import org.apache.phoenix.jdbc.HAGroupStateListener;
+import org.apache.phoenix.jdbc.HAGroupStoreManager;
+import org.apache.phoenix.jdbc.HAGroupStoreRecord;
+import org.apache.phoenix.jdbc.HAGroupStoreRecord.HAGroupState;
+import org.apache.phoenix.jdbc.HighAvailabilityPolicy;
+import org.apache.phoenix.replication.ReplicationLogGroup;
+import org.apache.phoenix.replication.ReplicationLogGroup.Status;
+import org.apache.phoenix.util.EnvironmentEdgeManager;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.rules.TestName;
+
+/**
+ * Unit tests for the role/policy decision in {@link 
IndexRegionObserver#getReplicationLogGroup}:
+ * which live write batches get a replication log writer, which commit locally 
without one, and
+ * which are rejected as split-brain. Also covers the WAL-restore path for 
PARALLEL groups.
+ */
+public class IndexRegionObserverReplicationLogGroupTest {
+
+  @Rule
+  public TemporaryFolder localFolder = new TemporaryFolder();
+  @Rule
+  public TemporaryFolder peerFolder = new TemporaryFolder();
+  @Rule
+  public TestName name = new TestName();
+
+  private Configuration conf;
+  private ServerName serverName;
+  private RegionCoprocessorEnvironment env;
+  private HAGroupStoreManager haGroupStoreManager;
+  private String haGroupName;
+
+  @Before
+  public void setUp() {
+    conf = HBaseConfiguration.create();
+    conf.setInt(ReplicationLogGroup.REPLICATION_LOG_RINGBUFFER_SIZE_KEY, 32);
+    conf.setLong(ReplicationLogGroup.REPLICATION_LOG_SYNC_TIMEOUT_KEY, 1000);
+    serverName = ServerName.valueOf("test", 60010, 
EnvironmentEdgeManager.currentTimeMillis());
+    env = mock(RegionCoprocessorEnvironment.class);
+    when(env.getConfiguration()).thenReturn(conf);
+    when(env.getServerName()).thenReturn(serverName);
+    haGroupStoreManager = mock(HAGroupStoreManager.class);
+    haGroupName = name.getMethodName();
+  }
+
+  private void stubRecord(HAGroupState state, HighAvailabilityPolicy policy) 
throws IOException {
+    HAGroupStoreRecord record = new HAGroupStoreRecord(null, haGroupName, 
state, 0,
+      policy.toString(), "peerZKUrl", "clusterUrl", "peerClusterUrl",
+      new Path(localFolder.getRoot().toString()).toUri().toString(),
+      new Path(peerFolder.getRoot().toString()).toUri().toString(), 0L);
+    doReturn(Optional.of(record)).when(haGroupStoreManager)
+      .getEffectiveHAGroupStoreRecord(anyString());
+  }
+
+  private ReplicationLogGroup resolve(IndexRegionObserver iro, BatchOrigin 
origin,
+    Optional<String> group) throws IOException {
+    return iro.getReplicationLogGroup(env, haGroupStoreManager, origin, group);
+  }
+
+  private ReplicationLogGroup resolveClientHa() throws IOException {
+    return resolve(new IndexRegionObserver("T", true), BatchOrigin.CLIENT_HA,
+      Optional.of(haGroupName));
+  }
+
+  /** No writer was constructed: a live group subscribes to LOCAL role changes 
in init(). */
+  private void verifyNoWriterCreated() throws Exception {
+    verify(haGroupStoreManager, never()).subscribeToTargetState(anyString(),
+      any(HAGroupState.class), any(ClusterType.class), 
any(HAGroupStateListener.class));
+  }
+
+  private void assertParallelSkips(HAGroupState state) throws Exception {
+    stubRecord(state, HighAvailabilityPolicy.PARALLEL);
+    ReplicationLogGroup logGroup = resolveClientHa();
+    try {
+      assertEquals("PARALLEL write must not ship through the replication log", 
Status.SKIP,
+        logGroup.getStatus());
+      assertEquals(haGroupName, logGroup.getHAGroupName());
+      // Cached: later batches do not re-read the store.
+      assertSame(logGroup, resolveClientHa());
+      verify(haGroupStoreManager, 
times(1)).getEffectiveHAGroupStoreRecord(haGroupName);
+      verifyNoWriterCreated();
+    } finally {
+      logGroup.close();
+    }
+  }
+
+  @Test
+  public void testParallelOnActiveSkips() throws Exception {
+    // PARALLEL clients write both clusters themselves, so the active must not 
ship the batch
+    // through the replication log (no append, no pre-image, no WAL-key stamp).
+    assertParallelSkips(HAGroupState.ACTIVE_IN_SYNC);
+  }
+
+  @Test
+  public void testParallelOnStandbySkips() throws Exception {
+    assertParallelSkips(HAGroupState.STANDBY);
+  }
+
+  @Test
+  public void testFailoverOnActiveReplicates() throws Exception {
+    stubRecord(HAGroupState.ACTIVE_IN_SYNC, HighAvailabilityPolicy.FAILOVER);
+    ReplicationLogGroup logGroup = resolveClientHa();
+    try {
+      assertEquals(Status.REPLICATING, logGroup.getStatus());
+      assertEquals(haGroupName, logGroup.getHAGroupName());
+    } finally {
+      logGroup.close();
+    }
+  }
+
+  @Test
+  public void testFailoverOnStandbyThrows() throws Exception {
+    stubRecord(HAGroupState.STANDBY, HighAvailabilityPolicy.FAILOVER);
+    try {
+      resolveClientHa();
+      fail("FAILOVER write on a non-active cluster must be rejected as 
split-brain");
+    } catch (IOException expected) {
+      assertTrue(expected.getMessage(), expected.getMessage().contains("not 
active"));
+    }
+    verifyNoWriterCreated();
+  }
+
+  @Test
+  public void testMissingRecordFails() throws Exception {
+    // With no record the policy is unknown, so resolution fails rather than 
committing.
+    doReturn(Optional.empty()).when(haGroupStoreManager)
+      .getEffectiveHAGroupStoreRecord(anyString());
+    try {
+      resolveClientHa();
+      fail("a missing HA group record must not silently skip replication");
+    } catch (IOException expected) {
+      assertTrue(expected.getMessage(), expected.getMessage().contains("empty 
group store record"));
+    }
+    verifyNoWriterCreated();
+  }
+
+  @Test
+  public void testNonClientHaOriginsReturnNoneWithoutLookup() throws Exception 
{
+    IndexRegionObserver iro = new IndexRegionObserver("T", true);
+    assertSame(ReplicationLogGroup.NONE, resolve(iro, 
BatchOrigin.CLIENT_NON_HA, Optional.empty()));
+    assertSame(ReplicationLogGroup.NONE, resolve(iro, BatchOrigin.NATIVE_IN, 
Optional.empty()));
+    assertSame(ReplicationLogGroup.NONE, resolve(iro, BatchOrigin.PHX_REPLAY, 
Optional.empty()));
+    verifyNoInteractions(haGroupStoreManager);
+  }
+
+  @Test
+  public void testNonReplicatedTableReturnsNoneWithoutLookup() throws 
Exception {
+    IndexRegionObserver iro = new IndexRegionObserver("T", false);
+    assertSame(ReplicationLogGroup.NONE,
+      resolve(iro, BatchOrigin.CLIENT_HA, Optional.of(haGroupName)));
+    verifyNoInteractions(haGroupStoreManager);
+  }
+
+  /**
+   * A WAL edit stamped with a PARALLEL group (e.g. written before PARALLEL 
skipped the log) must
+   * not be re-shipped on WAL restore, and must not create a writer.
+   */
+  @Test
+  @SuppressWarnings("unchecked")
+  public void testPreWALRestoreSkipsParallelGroup() throws Exception {
+    stubRecord(HAGroupState.ACTIVE_IN_SYNC, HighAvailabilityPolicy.PARALLEL);
+    // Prime the cache with the PARALLEL no-op group; preWALRestore resolves 
through the same
+    // (server, HA group) cache entry.
+    ReplicationLogGroup logGroup = resolveClientHa();
+    try {
+      ObserverContext<RegionCoprocessorEnvironment> ctx = 
mock(ObserverContext.class);
+      when(ctx.getEnvironment()).thenReturn(env);
+      WALKey logKey = mock(WALKey.class);
+      when(logKey.getExtendedAttributes()).thenReturn(Collections.singletonMap(
+        BaseScannerRegionObserverConstants.HA_GROUP_NAME_ATTRIB, 
Bytes.toBytes(haGroupName)));
+      WALEdit logEdit = mock(WALEdit.class);
+
+      new IndexRegionObserver("T", true).preWALRestore(ctx, null, logKey, 
logEdit);
+
+      // A re-ship would read the edit's cells; no interaction proves 
preWALRestore skipped it.
+      verifyNoInteractions(logEdit);
+      assertSame(logGroup, resolveClientHa());
+    } finally {
+      logGroup.close();
+    }
+  }
+}
diff --git 
a/phoenix-core/src/test/java/org/apache/phoenix/replication/ReplicationLogGroupTest.java
 
b/phoenix-core/src/test/java/org/apache/phoenix/replication/ReplicationLogGroupTest.java
index 25dbf4b45f..1e0854f2f3 100644
--- 
a/phoenix-core/src/test/java/org/apache/phoenix/replication/ReplicationLogGroupTest.java
+++ 
b/phoenix-core/src/test/java/org/apache/phoenix/replication/ReplicationLogGroupTest.java
@@ -1468,9 +1468,9 @@ public class ReplicationLogGroupTest extends 
ReplicationLogBaseTest {
 
     // Get instances for the first HA group
     ReplicationLogGroup g1_1 =
-      ReplicationLogGroup.get(conf, serverName, haGroupId1, 
haGroupStoreManager).get();
+      ReplicationLogGroup.get(conf, serverName, haGroupId1, 
haGroupStoreManager);
     ReplicationLogGroup g1_2 =
-      ReplicationLogGroup.get(conf, serverName, haGroupId1, 
haGroupStoreManager).get();
+      ReplicationLogGroup.get(conf, serverName, haGroupId1, 
haGroupStoreManager);
 
     // Verify same instance is returned for same haGroupId
     assertNotNull("ReplicationLogGroup should not be null", g1_1);
@@ -1480,16 +1480,16 @@ public class ReplicationLogGroupTest extends 
ReplicationLogBaseTest {
 
     // Get instance for a different HA group
     ReplicationLogGroup g2_1 =
-      ReplicationLogGroup.get(conf, serverName, haGroupId2, 
haGroupStoreManager).get();
+      ReplicationLogGroup.get(conf, serverName, haGroupId2, 
haGroupStoreManager);
     assertNotNull("ReplicationLogGroup should not be null", g2_1);
     assertTrue("Different instance should be returned for different 
haGroupId", g2_1 != g1_1);
     assertEquals("HA Group name should match", haGroupId2, 
g2_1.getHAGroupName());
 
     // Verify multiple calls still return cached instances
     ReplicationLogGroup g1_3 =
-      ReplicationLogGroup.get(conf, serverName, haGroupId1, 
haGroupStoreManager).get();
+      ReplicationLogGroup.get(conf, serverName, haGroupId1, 
haGroupStoreManager);
     ReplicationLogGroup g2_2 =
-      ReplicationLogGroup.get(conf, serverName, haGroupId2, 
haGroupStoreManager).get();
+      ReplicationLogGroup.get(conf, serverName, haGroupId2, 
haGroupStoreManager);
     assertTrue("Cached instance should be returned", g1_3 == g1_1);
     assertTrue("Cached instance should be returned", g2_2 == g2_1);
 
@@ -1516,9 +1516,8 @@ public class ReplicationLogGroupTest extends 
ReplicationLogBaseTest {
       ServerName.valueOf("10.244.1.25", serverName.getPort(), 
serverName.getStartcode());
 
     ReplicationLogGroup viaFqdn =
-      ReplicationLogGroup.get(conf, asFqdn, haGroupId, 
haGroupStoreManager).get();
-    ReplicationLogGroup viaIp =
-      ReplicationLogGroup.get(conf, asIp, haGroupId, 
haGroupStoreManager).get();
+      ReplicationLogGroup.get(conf, asFqdn, haGroupId, haGroupStoreManager);
+    ReplicationLogGroup viaIp = ReplicationLogGroup.get(conf, asIp, haGroupId, 
haGroupStoreManager);
 
     assertSame("Two host spellings of the same (port, startcode) must share 
one instance", viaFqdn,
       viaIp);
@@ -1528,7 +1527,7 @@ public class ReplicationLogGroupTest extends 
ReplicationLogBaseTest {
     ServerName otherServer =
       ServerName.valueOf(asIp.getHostname(), serverName.getPort() + 1, 
serverName.getStartcode());
     ReplicationLogGroup viaOther =
-      ReplicationLogGroup.get(conf, otherServer, haGroupId, 
haGroupStoreManager).get();
+      ReplicationLogGroup.get(conf, otherServer, haGroupId, 
haGroupStoreManager);
     assertNotEquals("A distinct port must yield a distinct instance", viaFqdn, 
viaOther);
 
     viaFqdn.close();
@@ -1545,13 +1544,13 @@ public class ReplicationLogGroupTest extends 
ReplicationLogBaseTest {
 
     // Get initial instance
     ReplicationLogGroup g1_1 =
-      ReplicationLogGroup.get(conf, serverName, haGroupId, 
haGroupStoreManager).get();
+      ReplicationLogGroup.get(conf, serverName, haGroupId, 
haGroupStoreManager);
     assertNotNull("ReplicationLogGroup should not be null", g1_1);
     assertFalse("Group should not be closed initially", g1_1.isClosed());
 
     // Verify cached instance is returned
     ReplicationLogGroup g1_2 =
-      ReplicationLogGroup.get(conf, serverName, haGroupId, 
haGroupStoreManager).get();
+      ReplicationLogGroup.get(conf, serverName, haGroupId, 
haGroupStoreManager);
     assertTrue("Same instance should be returned before close", g1_2 == g1_1);
 
     // Close the group
@@ -1560,7 +1559,7 @@ public class ReplicationLogGroupTest extends 
ReplicationLogBaseTest {
 
     // Get instance after close - should be a new instance
     ReplicationLogGroup g1_3 =
-      ReplicationLogGroup.get(conf, serverName, haGroupId, 
haGroupStoreManager).get();
+      ReplicationLogGroup.get(conf, serverName, haGroupId, 
haGroupStoreManager);
     assertNotNull("ReplicationLogGroup should not be null after close", g1_3);
     assertFalse("New group should not be closed", g1_3.isClosed());
     assertTrue("New instance should be created after close", g1_1 != g1_3);
@@ -1613,7 +1612,7 @@ public class ReplicationLogGroupTest extends 
ReplicationLogBaseTest {
 
       // A get() in this window must return the SAME (closing) instance, not a 
fresh one.
       ReplicationLogGroup duringClose =
-        ReplicationLogGroup.get(conf, serverName, haGroupId, 
haGroupStoreManager).get();
+        ReplicationLogGroup.get(conf, serverName, haGroupId, 
haGroupStoreManager);
       assertSame("A get() during close()'s teardown must not resurrect a 
second instance", group,
         duringClose);
 
@@ -1624,7 +1623,7 @@ public class ReplicationLogGroupTest extends 
ReplicationLogBaseTest {
       // After close() fully completes, the entry is gone, so a later get() 
creates a fresh
       // instance.
       ReplicationLogGroup afterClose =
-        ReplicationLogGroup.get(conf, serverName, haGroupId, 
haGroupStoreManager).get();
+        ReplicationLogGroup.get(conf, serverName, haGroupId, 
haGroupStoreManager);
       assertNotSame("A get() after close() completes must create a new 
instance", group,
         afterClose);
       afterClose.close();
@@ -1670,7 +1669,7 @@ public class ReplicationLogGroupTest extends 
ReplicationLogBaseTest {
     // Despite the thrown teardown, the cache entry must be gone: a fresh 
get() creates a NEW,
     // usable instance rather than returning the stranded closed one.
     ReplicationLogGroup afterClose =
-      ReplicationLogGroup.get(conf, serverName, haGroupId, 
haGroupStoreManager).get();
+      ReplicationLogGroup.get(conf, serverName, haGroupId, 
haGroupStoreManager);
     assertNotSame("A stranded closed instance must not be returned after a 
throwing close()", group,
       afterClose);
     assertFalse("Re-created instance must be usable, not closed", 
afterClose.isClosed());
@@ -1710,7 +1709,7 @@ public class ReplicationLogGroupTest extends 
ReplicationLogBaseTest {
 
     // Cache entry removed: a fresh get() creates a NEW instance.
     ReplicationLogGroup afterClose =
-      ReplicationLogGroup.get(conf, serverName, haGroupId, 
haGroupStoreManager).get();
+      ReplicationLogGroup.get(conf, serverName, haGroupId, 
haGroupStoreManager);
     assertNotSame("close() must remove the cache entry even when unsubscribe 
throws", group,
       afterClose);
     afterClose.close();
@@ -3058,31 +3057,142 @@ public class ReplicationLogGroupTest extends 
ReplicationLogBaseTest {
     return result;
   }
 
+  private HAGroupStoreManager mockManagerFor(String group, HAGroupState state,
+    HighAvailabilityPolicy policy) throws IOException {
+    HAGroupStoreManager manager = Mockito.mock(HAGroupStoreManager.class);
+    stubEffectiveRecord(manager, group, state, policy);
+    return manager;
+  }
+
+  private void stubEffectiveRecord(HAGroupStoreManager manager, String group, 
HAGroupState state,
+    HighAvailabilityPolicy policy) throws IOException {
+    HAGroupStoreRecord record = new HAGroupStoreRecord(null, group, state, 0, 
policy.toString(),
+      "peerZKUrl", "clusterUrl", "peerClusterUrl", localUri.toString(), 
peerUri.toString(), 0L);
+    
doReturn(Optional.of(record)).when(manager).getEffectiveHAGroupStoreRecord(anyString());
+  }
+
   /**
    * A replication log group is a writer and must exist only where the local 
cluster is active.
-   * get() must return empty (and not cache) on a non-active role (here 
STANDBY), before any writer
-   * resource is constructed — no group instance, no forwarder, no demotion 
subscription.
+   * get() must return NOT_ACTIVE (and not cache) on a non-active role (here 
STANDBY), before any
+   * writer resource is constructed — no forwarder, no demotion subscription. 
Once the role flips to
+   * active the next get() re-reads the store and builds the writer.
    */
   @Test
-  public void testGetReturnsEmptyOnStandby() throws Exception {
+  public void testGetReturnsNotActiveOnStandbyUntilPromoted() throws Exception 
{
     // Use a fresh HA group + its own mock manager so the never() 
verifications observe only this
     // get() attempt, independent of the active logGroup created by 
setUpBase().
     final String standbyGroup = haGroupName + "-standby";
-    HAGroupStoreManager standbyManager = 
Mockito.mock(HAGroupStoreManager.class);
-    HAGroupStoreRecord standbyRecord = new HAGroupStoreRecord(null, 
standbyGroup,
-      HAGroupState.STANDBY, 0, HighAvailabilityPolicy.FAILOVER.toString(), 
"peerZKUrl",
-      "clusterUrl", "peerClusterUrl", localUri.toString(), peerUri.toString(), 
0L);
-    doReturn(Optional.of(standbyRecord)).when(standbyManager)
-      .getEffectiveHAGroupStoreRecord(anyString());
-
-    Optional<ReplicationLogGroup> group =
-      ReplicationLogGroup.get(conf, serverName, standbyGroup, standbyManager);
-    assertFalse("get() must return empty on a non-active (STANDBY) role", 
group.isPresent());
+    HAGroupStoreManager standbyManager =
+      mockManagerFor(standbyGroup, HAGroupState.STANDBY, 
HighAvailabilityPolicy.FAILOVER);
 
+    for (int i = 0; i < 3; i++) {
+      ReplicationLogGroup group =
+        ReplicationLogGroup.get(conf, serverName, standbyGroup, 
standbyManager);
+      assertEquals(ReplicationLogGroup.Status.NOT_ACTIVE, group.getStatus());
+      assertFalse(group.isReplicated());
+    }
+    // Not cached: every call re-read the store.
+    verify(standbyManager, 
times(3)).getEffectiveHAGroupStoreRecord(standbyGroup);
     // No writer resources should have been created: the role gate runs before 
construction, so no
     // group exists to subscribe any state listener.
     verify(standbyManager, never()).subscribeToTargetState(eq(standbyGroup),
       any(HAGroupState.class), eq(ClusterType.LOCAL), 
any(HAGroupStateListener.class));
+
+    // Promotion: the next get() builds the writer.
+    stubEffectiveRecord(standbyManager, standbyGroup, 
HAGroupState.ACTIVE_IN_SYNC,
+      HighAvailabilityPolicy.FAILOVER);
+    ReplicationLogGroup promoted =
+      ReplicationLogGroup.get(conf, serverName, standbyGroup, standbyManager);
+    try {
+      assertEquals(ReplicationLogGroup.Status.REPLICATING, 
promoted.getStatus());
+      assertEquals(standbyGroup, promoted.getHAGroupName());
+      verify(standbyManager, 
atLeastOnce()).subscribeToTargetState(eq(standbyGroup),
+        any(HAGroupState.class), eq(ClusterType.LOCAL), 
any(HAGroupStateListener.class));
+    } finally {
+      promoted.close();
+    }
+  }
+
+  /**
+   * A PARALLEL group never uses the replication log, so get() returns a 
cached no-op SKIP group on
+   * either role: one store read across many calls, and no writer resources 
(no state
+   * subscriptions).
+   */
+  @Test
+  public void testParallelGetReturnsCachedSkipGroup() throws Exception {
+    for (HAGroupState state : new HAGroupState[] { HAGroupState.ACTIVE_IN_SYNC,
+      HAGroupState.STANDBY }) {
+      final String parallelGroup = haGroupName + "-parallel-" + state;
+      HAGroupStoreManager manager =
+        mockManagerFor(parallelGroup, state, HighAvailabilityPolicy.PARALLEL);
+
+      ReplicationLogGroup first = ReplicationLogGroup.get(conf, serverName, 
parallelGroup, manager);
+      try {
+        assertEquals(state.toString(), ReplicationLogGroup.Status.SKIP, 
first.getStatus());
+        assertFalse(first.isReplicated());
+        assertEquals(parallelGroup, first.getHAGroupName());
+        for (int i = 0; i < 5; i++) {
+          assertSame(first, ReplicationLogGroup.get(conf, serverName, 
parallelGroup, manager));
+        }
+        verify(manager, 
times(1)).getEffectiveHAGroupStoreRecord(parallelGroup);
+        verify(manager, never()).subscribeToTargetState(anyString(), 
any(HAGroupState.class),
+          any(ClusterType.class), any(HAGroupStateListener.class));
+        try {
+          first.sync();
+          fail("a no-op group must not accept a sync");
+        } catch (IllegalStateException expected) {
+        }
+      } finally {
+        first.close();
+      }
+    }
+  }
+
+  /** close() and closeAll() on a PARALLEL no-op group evict it, so the next 
get() re-reads. */
+  @Test
+  public void testParallelSkipGroupCloseAndCloseAll() throws Exception {
+    final String parallelGroup = haGroupName + "-parallel";
+    HAGroupStoreManager manager =
+      mockManagerFor(parallelGroup, HAGroupState.ACTIVE_IN_SYNC, 
HighAvailabilityPolicy.PARALLEL);
+
+    ReplicationLogGroup first = ReplicationLogGroup.get(conf, serverName, 
parallelGroup, manager);
+    first.close();
+    assertTrue(first.isClosed());
+    ReplicationLogGroup second = ReplicationLogGroup.get(conf, serverName, 
parallelGroup, manager);
+    assertNotSame("close() must evict the no-op group", first, second);
+
+    ReplicationLogGroup.closeAll(serverName);
+    assertTrue(second.isClosed());
+    ReplicationLogGroup third = ReplicationLogGroup.get(conf, serverName, 
parallelGroup, manager);
+    try {
+      assertNotSame("closeAll() must evict the no-op group", second, third);
+      verify(manager, times(3)).getEffectiveHAGroupStoreRecord(parallelGroup);
+    } finally {
+      third.close();
+    }
+  }
+
+  /** The shared NONE group is not tied to any HA group and survives close(). 
*/
+  @Test
+  public void testSharedNoneGroupIgnoresClose() {
+    ReplicationLogGroup.NONE.close();
+    assertFalse(ReplicationLogGroup.NONE.isClosed());
+    assertEquals(ReplicationLogGroup.Status.SKIP, 
ReplicationLogGroup.NONE.getStatus());
+  }
+
+  /** A ReplicationLog whose first writer cannot be created must not leave 
rotation running. */
+  @Test
+  public void testFailedLogInitStopsRotationExecutor() throws Exception {
+    ReplicationLog log = 
logGroup.createReplicationLog(logGroup.getLocalShardManager());
+    doThrow(new IOException("Simulated writer creation 
failure")).when(log).createNewWriter();
+    try {
+      log.init();
+      fail("init() must fail when the first writer cannot be created");
+    } catch (IOException expected) {
+    }
+    assertTrue("A failed init must close the log", log.isClosed());
+    assertTrue("Rotation executor must be stopped after a failed init",
+      log.rotationExecutor == null || log.rotationExecutor.isTerminated());
   }
 
   /**

Reply via email to