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 f5063fc7e1f..23c0ef3fd30 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 @@ -42,6 +42,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -52,10 +53,13 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.function.Predicate; +import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.hbase.Abortable; import org.apache.hadoop.hbase.Cell; +import org.apache.hadoop.hbase.CellBuilderFactory; +import org.apache.hadoop.hbase.CellBuilderType; import org.apache.hadoop.hbase.CellComparator; import org.apache.hadoop.hbase.CellScanner; import org.apache.hadoop.hbase.CellUtil; @@ -114,6 +118,9 @@ import org.apache.phoenix.hbase.index.builder.IndexBuildManager; import org.apache.phoenix.hbase.index.builder.IndexBuilder; import org.apache.phoenix.hbase.index.covered.IndexMetaData; +import org.apache.phoenix.hbase.index.covered.data.CachedLocalTable; +import org.apache.phoenix.hbase.index.covered.data.LocalHBaseState; +import org.apache.phoenix.hbase.index.covered.data.PreImageLocalTable; import org.apache.phoenix.hbase.index.covered.update.ColumnReference; import org.apache.phoenix.hbase.index.metrics.MetricsHaBypassSourceFactory; import org.apache.phoenix.hbase.index.metrics.MetricsIndexerSource; @@ -121,12 +128,13 @@ import org.apache.phoenix.hbase.index.table.HTableInterfaceReference; import org.apache.phoenix.hbase.index.util.GenericKeyValueBuilder; import org.apache.phoenix.hbase.index.util.ImmutableBytesPtr; -import org.apache.phoenix.hbase.index.wal.IndexedKeyValue; import org.apache.phoenix.hbase.index.write.IndexWriter; import org.apache.phoenix.hbase.index.write.LazyParallelWriterIndexCommitter; import org.apache.phoenix.index.IndexMaintainer; import org.apache.phoenix.index.PhoenixIndexBuilderHelper; +import org.apache.phoenix.index.PhoenixIndexCodec; import org.apache.phoenix.index.PhoenixIndexMetaData; +import org.apache.phoenix.index.PhoenixIndexMetaDataBuilder; import org.apache.phoenix.jdbc.HAGroupStoreManager; import org.apache.phoenix.query.KeyRange; import org.apache.phoenix.query.QueryConstants; @@ -154,6 +162,7 @@ import org.apache.phoenix.util.EncodedColumnsUtil; import org.apache.phoenix.util.EnvironmentEdgeManager; import org.apache.phoenix.util.IndexUtil; +import org.apache.phoenix.util.MetaDataUtil; import org.apache.phoenix.util.MutationUtil; import org.apache.phoenix.util.PhoenixKeyValueUtil; import org.apache.phoenix.util.SchemaUtil; @@ -163,6 +172,7 @@ import org.slf4j.LoggerFactory; import org.xerial.snappy.Snappy; +import org.apache.phoenix.thirdparty.com.google.common.annotations.VisibleForTesting; import org.apache.phoenix.thirdparty.com.google.common.base.Preconditions; import org.apache.phoenix.thirdparty.com.google.common.collect.ArrayListMultimap; import org.apache.phoenix.thirdparty.com.google.common.collect.ListMultimap; @@ -250,6 +260,26 @@ public class IndexRegionObserver implements RegionCoprocessor, RegionObserver { public static final String PHOENIX_INDEX_CDC_MUTATION_SERIALIZE = "phoenix.index.cdc.mutation.serialize"; public static final boolean DEFAULT_PHOENIX_INDEX_CDC_MUTATION_SERIALIZE = false; + // Generic marker attribute set on every mutation produced by the standby reader from a + // replication-log record. Value is opaque (presence is the signal). Detected in + // preBatchMutateWithExceptions to set context.isReplication, which gates the primary-side clock + // work (getBatchTimestamp/setTimestamps) off so the cell timestamps shipped from the active + // cluster are preserved. Never set on primary-side mutations. + public static final String REPLICATED_MUTATION = "_ReplicatedMutation"; + // Per-row mutation attribute that the standby reader synthesizes from the pre-image cell and + // attaches to each reconstructed mutation when its row had a pre-image entry. Value is the + // PB-encoded primary-side Put (or empty bytes when the primary observed an empty row at lock + // time). IRO on the standby consumes this to derive each (row, ts) group's data-row state and + // write index updates directly, instead of scanning the data table — that scan is unsafe under + // out-of-order replay. Absent when the row had no pre-image (e.g. local-index-only or pure-data + // tables on the active). + public static final String PRE_IMAGE = "_PhoenixPreImage"; + // Qualifier for the per-row pre-image cell injected into the replication cell stream and the + // WAL edit at PRE phase. Cells with this (METAFAMILY, qualifier) pair carry a serialized PB Put + // representing the row's state on the primary before the current batch was applied. The standby + // reader peels these cells off and attaches the bytes as {@link #PRE_IMAGE} on the reconstructed + // mutation. + public static final byte[] PRE_IMAGE_WAL_QUALIFIER = Bytes.toBytes("_PhoenixPreImage"); /** * Class to represent pending data table rows @@ -333,6 +363,51 @@ public enum BatchMutatePhase { FAILED } + /** + * Composite key for {@link BatchMutateContext#cdcPreMutationsBytes} and + * {@link BatchMutateContext#cdcPostMutationsBytes} and for the standby's per-(row, ts) grouping. + * The active path always has ts == batchTimestamp, so the {@code (row, ts)} key behaves like + * {@code (row)} on the active. The standby can have multiple entries per row when records from + * two active-side batches for the same row coalesce in one mini-batch. + */ + public static final class RowTsKey { + private final ImmutableBytesPtr row; + private final long ts; + + public RowTsKey(ImmutableBytesPtr row, long ts) { + this.row = row; + this.ts = ts; + } + + public ImmutableBytesPtr getRow() { + return row; + } + + public long getTs() { + return ts; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof RowTsKey)) { + return false; + } + RowTsKey other = (RowTsKey) o; + return ts == other.ts && row.equals(other.row); + } + + @Override + public int hashCode() { + return 31 * row.hashCode() + Long.hashCode(ts); + } + + @Override + public String toString() { + return "RowTsKey [row=" + Bytes.toStringBinary(row.copyBytesIfNecessary()) + ", ts=" + ts + + "]"; + } + } + // Hack to get around not being able to save any state between // coprocessor calls. TODO: remove after HBASE-18127 when available @@ -361,15 +436,16 @@ public static class BatchMutateContext { // The collection of candidate index mutations that will be applied after the data table // mutations. private ListMultimap> indexUpdates; - // Map of data table row key to IndexMutations bytes - // containing pre-index mutations for eventually consistent indexes - // (index mutations with UNVERIFIED Puts only, no Deletes) - private Map cdcPreMutationsBytes; - // Map of data table row key to IndexMutations bytes - // containing post-index mutations for eventually consistent indexes - // (index mutations with VERIFIED Puts for covered, - // no Put mutations for uncovered, and Deletes if needed) - private Map cdcPostMutationsBytes; + // Map of (data table row key, group ts) to IndexMutations bytes containing pre-index mutations + // for eventually consistent indexes (UNVERIFIED Puts only, no Deletes). Keyed by (row, ts) so + // multiple per-row entries from the standby's per-(row, ts) grouping don't collide. On the + // active path each row produces exactly one entry (ts == batchTimestamp), so lookup is + // unchanged. + private Map cdcPreMutationsBytes; + // Map of (data table row key, group ts) to IndexMutations bytes containing post-index + // mutations for eventually consistent indexes (VERIFIED Puts for covered, no Put mutations + // for uncovered, and Deletes if needed). + private Map cdcPostMutationsBytes; private List rowLocks = Lists.newArrayListWithExpectedSize(QueryServicesOptions.DEFAULT_MUTATE_BATCH_SIZE); // TreeSet to improve locking efficiency and avoid deadlock (PHOENIX-6871 and HBASE-17924) @@ -405,8 +481,20 @@ public static class BatchMutateContext { private boolean returnOldRow; private boolean hasConditionalTTL; // table has Conditional TTL private boolean immutableRows; + // True when this batch was produced by the standby reader from a replication-log record (i.e. + // every mutation carries the {@link IndexRegionObserver#REPLICATED_MUTATION} marker). Batch- + // uniform by construction: the standby reader stamps every reconstructed mutation, so checking + // the first one is sufficient. The standby uses this to skip the data-table scan in the PRE + // phase (pre-image cells are carried as the per-row {@link IndexRegionObserver#PRE_IMAGE} + // attribute when the table has a global/uncovered/transform index — same schema on both + // clusters, so when we're inside that branch on the standby we always have pre-images). + private boolean isReplication; // HAGroup associated with the batch private Optional logGroup = Optional.empty(); + // 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 (isReplication). + private List replicatedRowGroups; public BatchMutateContext() { this.clientVersion = 0; @@ -466,6 +554,11 @@ public void countDownAllLatches() { public int getMaxPendingRowCount() { return maxPendingRowCount; } + + /** True if the batch's table carries any index the standby must regenerate. */ + public boolean hasIndex() { + return hasGlobalIndex || hasUncoveredIndex || hasLocalIndex || hasTransform; + } } private ThreadLocal batchMutateContext = @@ -560,6 +653,14 @@ private static Predicate getSynchronousReplicationFilter(byte[] tableN private Predicate ignoreReplicationFilter; + public IndexRegionObserver() { + } + + @VisibleForTesting + IndexRegionObserver(String dataTableName) { + this.dataTableName = dataTableName; + } + @Override public Optional getRegionObserver() { return Optional.of(this); @@ -824,9 +925,8 @@ private Optional getHAGroupFromBatch(RegionCoprocessorEnvir * @return HA group if present or empty if missing */ private Optional getHAGroupFromWALKey(RegionCoprocessorEnvironment env, - org.apache.hadoop.hbase.wal.WALKey logKey) throws IOException { - byte[] haGroupName = - logKey.getExtendedAttribute(BaseScannerRegionObserverConstants.HA_GROUP_NAME_ATTRIB); + Map walKeyAttrs) throws IOException { + byte[] haGroupName = walKeyAttrs.get(BaseScannerRegionObserverConstants.HA_GROUP_NAME_ATTRIB); if (haGroupName != null) { ReplicationLogGroup logGroup = ReplicationLogGroup.get(env.getConfiguration(), env.getServerName(), Bytes.toString(haGroupName), abortable); @@ -846,13 +946,15 @@ public void preWALRestore( if (!shouldReplicate) { return; } - Optional logGroup = getHAGroupFromWALKey(ctx.getEnvironment(), logKey); + Map walKeyAttrs = getAttributeValuesFromWALKey(logKey); + Optional logGroup = + getHAGroupFromWALKey(ctx.getEnvironment(), walKeyAttrs); if (!logGroup.isPresent()) { return; } long start = EnvironmentEdgeManager.currentTimeMillis(); try { - replicateEditOnWALRestore(logGroup.get(), logKey, logEdit); + replicateEditOnWALRestore(logGroup.get(), logKey, walKeyAttrs, logEdit); } finally { long duration = EnvironmentEdgeManager.currentTimeMillis() - start; metricSource.updatePreWALRestoreTime(dataTableName, duration); @@ -860,32 +962,47 @@ public void preWALRestore( } /** - * A batch of mutations is recorded in a single WAL edit so a WAL edit can have cells belonging to - * multiple rows. Further, for one mutation the WAL edit contains the individual cells that are - * part of the mutation. + * Forward the WAL edit's cell stream to the replication log as a single batch, filtered through + * {@link #isReplicableCell} — the same predicate the synchronous {@link #replicateMutations} path + * uses. The persisted WAL edit carries the local-index (L#) cells HBase merged into the data + * mutation's family map; those must be dropped (the standby regenerates its own local index), + * while the METAFAMILY pre-image cells injected at PRE phase are kept. The standby reader peels + * the pre-image cells and reconstructs Put/Delete mutations on the way out. * @param logGroup HA Group * @param logKey WAL log key * @param logEdit WAL edit record */ private void replicateEditOnWALRestore(ReplicationLogGroup logGroup, WALKey logKey, - WALEdit logEdit) throws IOException { - List regularCells = new ArrayList<>(); - for (Cell kv : logEdit.getCells()) { - if (kv instanceof IndexedKeyValue) { - IndexedKeyValue ikv = (IndexedKeyValue) kv; - logGroup.append(Bytes.toString(ikv.getIndexTable()), -1, ikv.getMutation()); - } else { - regularCells.add(kv); - } + Map walKeyAttrs, WALEdit logEdit) throws IOException { + String tableName = logKey.getTableName().getNameAsString(); + List cells = logEdit.getCells(); + if (cells == null || cells.isEmpty()) { + return; } - if (!regularCells.isEmpty()) { - String tableName = logKey.getTableName().getNameAsString(); - for (Mutation split : MutationCellGrouper.splitCellsIntoMutations(regularCells)) { - if (!this.ignoreReplicationFilter.test(split)) { - logGroup.append(tableName, -1, split); - } + // The persisted WAL edit carries the local-index (L#) cells HBase merged into the data + // mutation's family map, plus our METAFAMILY pre-image cells and possibly foreign coprocessor + // markers. Filter through the same predicate the synchronous path uses so both ship exactly the + // data cells and our pre-image, and never a local-index cell (the standby regenerates its own). + List replicable = + cells.stream().filter(IndexRegionObserver::isReplicableCell).collect(Collectors.toList()); + if (replicable.isEmpty()) { + return; + } + Map replicationAttrs = new HashMap<>(); + for (String attrKey : ReplicationLogGroup.REPLICATION_ATTR_KEYS) { + byte[] val = walKeyAttrs.get(attrKey); + if (val != null) { + replicationAttrs.put(attrKey, val); } } + // INDEX_UUID rides the WAL key only when appendReplicationAttributesToWALKey stamped it (i.e. + // the batch's table was indexed, gated on hasIndex()); copy it through verbatim so this path + // follows the same server-PTable resolution the synchronous path triggers. + byte[] indexUuid = walKeyAttrs.get(PhoenixIndexCodec.INDEX_UUID); + if (indexUuid != null) { + replicationAttrs.put(PhoenixIndexCodec.INDEX_UUID, indexUuid); + } + logGroup.append(tableName, -1, replicable, replicationAttrs); logGroup.sync(); } @@ -1234,19 +1351,7 @@ private boolean applyOnePendingDeleteMutation(BatchMutateContext context, Delete } } - for (List cells : delete.getFamilyCellMap().values()) { - for (Cell cell : cells) { - switch (cell.getType()) { - case DeleteFamily: - case DeleteFamilyVersion: - nextDataRowState.getFamilyCellMap().remove(CellUtil.cloneFamily(cell)); - break; - case DeleteColumn: - case Delete: - removeColumn(nextDataRowState, cell); - } - } - } + applyDeleteCells(delete, nextDataRowState); if (nextDataRowState != null && nextDataRowState.getFamilyCellMap().size() == 0) { dataRowState.setSecond(null); } @@ -1306,17 +1411,374 @@ private void prepareDataRowStates(ObserverContext applyPendingDeleteMutations(miniBatchOp, context); } + /** + * One {@code (row, ts)} group of a replicated mini-batch on the standby: the group's mutations, + * the per-row pre-image the active shipped for that batch, and the data-row state derived by + * folding the group's cells onto that pre-image. Built once by {@link #buildReplicatedRowGroups} + * and consumed by both the global-index path ({@link #prepareReplicatedIndexMutations}, which + * uses {@code preImage} + {@code nextState}) and the local-index path (which uses + * {@code preImage} as the builder's prior row state). Different {@code (row, ts)} groups for the + * same row are kept separate — that's how the standby recovers the active-batch boundary the + * reader's coalescing can erase. + */ + static final class ReplicatedRowGroup { + final ImmutableBytesPtr row; + final long ts; + final List mutations; + final Put preImage; + final Put nextState; + + ReplicatedRowGroup(ImmutableBytesPtr row, long ts, List mutations, Put preImage, + Put nextState) { + this.row = row; + this.ts = ts; + this.mutations = mutations; + this.preImage = preImage; + this.nextState = nextState; + } + } + + /** + * Group already-enabled replicated mutations by {@code (row, ts)} and, for each group, decode the + * shipped {@link #PRE_IMAGE} (from the group's first mutation — the active wrote one pre-image + * cell per row per batch, so all mutations in a group share it) and fold the group's cells onto + * it to derive the next data-row state. Groups are returned in first-seen order. The caller is + * responsible for filtering out non-indexed mutations (e.g. via {@code builder.isEnabled}) before + * calling this. + */ + @VisibleForTesting + List buildReplicatedRowGroups(List enabledMutations) + throws IOException { + LinkedHashMap> groups = new LinkedHashMap<>(); + for (Mutation m : enabledMutations) { + RowTsKey key = new RowTsKey(new ImmutableBytesPtr(m.getRow()), IndexUtil.getMaxTimestamp(m)); + groups.computeIfAbsent(key, k -> new ArrayList<>()).add(m); + } + List result = new ArrayList<>(groups.size()); + for (Map.Entry> entry : groups.entrySet()) { + List groupMutations = entry.getValue(); + Put preImage = decodePreImage(groupMutations.get(0)); + Put nextState = deriveNextState(preImage, groupMutations); + result.add(new ReplicatedRowGroup(entry.getKey().getRow(), entry.getKey().getTs(), + groupMutations, preImage, nextState)); + } + return result; + } + + /** + * Collect the mini-batch's index-enabled mutations into a list, preserving order. + */ + private List enabledMutations(MiniBatchOperationInProgress miniBatchOp) { + List enabled = new ArrayList<>(miniBatchOp.size()); + for (int i = 0; i < miniBatchOp.size(); i++) { + Mutation m = miniBatchOp.getOperation(i); + if (this.builder.isEnabled(m)) { + enabled.add(m); + } + } + return enabled; + } + + /** + * Fold this replicated batch into {@code (row, ts)} groups, once per batch. A table with both a + * global and a local index reaches this from both index paths; caching on the context avoids + * re-walking the mini-batch and re-parsing every group's pre-image protobuf a second time. + */ + private List getReplicatedRowGroups( + MiniBatchOperationInProgress miniBatchOp, BatchMutateContext context) + throws IOException { + if (context.replicatedRowGroups == null) { + context.replicatedRowGroups = buildReplicatedRowGroups(enabledMutations(miniBatchOp)); + } + return context.replicatedRowGroups; + } + + /** + * Build the local-index replay inputs from already-grouped replicated mutations: one uniform-ts + * {@link MultiMutation} per {@code (row, ts)} group (the pending mutations the local builder + * processes) and, as a side effect, populate {@code preImageCellsByRowTs} mapping each group's + * {@code (row, ts)} to the pre-image cells the active shipped (the builder's prior row state). + * The map is keyed by {@code (row, ts)} so a row recurring across concatenated active batches + * gets each batch's own pre-image rather than collapsing to the earliest. A {@code null} + * pre-image (active saw an empty row) maps to a {@code null} cell list — the documented "no prior + * row" sentinel. The returned {@link MultiMutation}s mirror what {@code groupMutations} produces + * on the active, but grouped by {@code (row, ts)} so each is uniform-ts as + * {@code NonTxIndexBuilder} requires. + */ + private Collection buildReplayLocalIndexInputs( + List groups, Map> preImageCellsByRowTs) { + List pending = new ArrayList<>(groups.size()); + for (ReplicatedRowGroup group : groups) { + MultiMutation mm = new MultiMutation(group.row); + for (Mutation m : group.mutations) { + mm.addAll(m); + } + pending.add(mm); + List priorCells = + group.preImage == null ? null : MutationCellGrouper.flattenCells(group.preImage); + preImageCellsByRowTs.put(new RowTsKey(group.row, group.ts), priorCells); + } + return pending; + } + + /** + * Standby-side counterpart to {@link #prepareIndexMutations}. Groups the mini-batch's mutations + * by {@code (row, ts)} so all mutations from the same active-side batch on the same row are + * processed together against one shared pre-image. Different {@code (row, ts)} groups for the + * same row produce separate index updates — that's how the standby recovers the active-batch + * boundary that the reader's coalescing can erase. + *

+ * For each group: decode {@link #PRE_IMAGE} from the first mutation (all mutations in a group + * share one pre-image because the active wrote one pre-image cell per row per batch), apply the + * group's cells on top to derive {@code nextDataRowState}, then call + * {@link #generateIndexMutationsForRow} with the group's ts so the resulting index Mutation cells + * carry the correct timestamp. + *

+ * Skips {@code getCurrentRowStates} (unsafe under out-of-order replay) and writes directly to + * {@code context.indexUpdates}. + */ + private void prepareReplicatedIndexMutations(MiniBatchOperationInProgress miniBatchOp, + BatchMutateContext context, List maintainers) throws IOException { + List> indexTables = + buildIndexTablesList(maintainers); + for (ReplicatedRowGroup group : getReplicatedRowGroups(miniBatchOp, context)) { + if (group.preImage == null && group.nextState == null) { + continue; + } + ListMultimap idxUpdates = ArrayListMultimap.create(); + generateIndexMutationsForRow(group.row, group.preImage, group.nextState, group.ts, + encodedRegionName, QueryConstants.UNVERIFIED_BYTES, indexTables, idxUpdates); + for (Map.Entry idxUpdate : idxUpdates.entries()) { + context.indexUpdates.put(idxUpdate.getKey(), + new Pair<>(idxUpdate.getValue(), group.row.get())); + } + } + } + + /** + * Decodes the {@link #PRE_IMAGE} attribute on a standby-side replicated mutation. Returns + * {@code null} when the active observed an empty row at lock time (sentinel: zero-length value). + * Throws when the attribute is absent — that signals a contract violation: an indexed-table + * mutation arrived on the standby with no pre-image, which should never happen because the active + * runs {@code prepareDataRowStates} (and writes a pre-image cell) for every table with a + * global/uncovered/transform/CDC index. + *

+ * The one way this can legitimately occur is schema skew: the standby carries an index the active + * lacked when it shipped the batch, so the mutation is index-enabled on replay but no pre-image + * was captured on the active. That already breaks the feature's foundational assumption — index + * regeneration requires the active and standby to agree on the set of index maintainers — so we + * fail loud (a non-retryable {@link DoNotRetryIOException}) rather than silently regenerate the + * index against a missing prior state, which would corrupt it. + */ + @VisibleForTesting + Put decodePreImage(Mutation m) throws IOException { + byte[] preImageBytes = m.getAttribute(PRE_IMAGE); + if (preImageBytes == null) { + throw new DoNotRetryIOException("Replicated mutation on table " + dataTableName + + " is missing the " + PRE_IMAGE + " attribute: row=" + Bytes.toStringBinary(m.getRow())); + } + if (preImageBytes.length == 0) { + return null; + } + return ProtobufUtil.toPut(ClientProtos.MutationProto.parseFrom(preImageBytes)); + } + + /** + * Encode a pre-image Put into the bytes carried by the {@link #PRE_IMAGE_WAL_QUALIFIER} cell + * (and, after the reader peels it off, the {@link #PRE_IMAGE} attribute). A {@code null} + * pre-image means the active side saw an empty row; it encodes to a zero-length array, the + * sentinel {@link #decodePreImage} maps back to {@code null}. + */ + @VisibleForTesting + static byte[] encodePreImage(Put preImage) throws IOException { + return preImage != null + ? ProtobufUtil.toMutation(ClientProtos.MutationProto.MutationType.PUT, preImage).toByteArray() + : HConstants.EMPTY_BYTE_ARRAY; + } + + /** + * Build the METAFAMILY pre-image cell that the active appends to the WAL edit (and that the + * reader peels off into the {@link #PRE_IMAGE} attribute). {@code priorState} is the row state + * the active observed at lock time; {@code null} encodes to the empty-row sentinel. + */ + @VisibleForTesting + public static Cell buildPreImageCell(byte[] row, Put priorState) throws IOException { + return CellBuilderFactory.create(CellBuilderType.SHALLOW_COPY).setRow(row) + .setFamily(WALEdit.METAFAMILY).setQualifier(PRE_IMAGE_WAL_QUALIFIER) + .setTimestamp(HConstants.LATEST_TIMESTAMP).setType(Cell.Type.Put) + .setValue(encodePreImage(priorState)).build(); + } + + /** + * Applies a Delete's cells to a Put, returning the resulting Put or {@code null} if the row goes + * empty. Used by {@link #prepareReplicatedIndexMutations} to derive {@code nextDataRowState} from + * a (preImage + cells) sequence within one (row, ts) group. + */ + @VisibleForTesting + static Put applyDeleteToPut(Delete delete, Put put) { + if (put == null) { + return null; + } + applyDeleteCells(delete, put); + return put.getFamilyCellMap().isEmpty() ? null : put; + } + + /** + * Applies a Delete's tombstone cells to {@code put} in place: DeleteFamily/DeleteFamilyVersion + * drop the whole family, DeleteColumn/Delete drop the column. Shared by {@link #applyDeleteToPut} + * (replay next-state derivation) and {@link #applyOnePendingDeleteMutation} (active next-state). + */ + private static void applyDeleteCells(Delete delete, Put put) { + for (List cells : delete.getFamilyCellMap().values()) { + for (Cell cell : cells) { + switch (cell.getType()) { + case DeleteFamily: + case DeleteFamilyVersion: + put.getFamilyCellMap().remove(CellUtil.cloneFamily(cell)); + break; + case DeleteColumn: + case Delete: + removeColumn(put, cell); + break; + default: + break; + } + } + } + } + + /** + * Fold a (row, ts) group's mutations onto its pre-image to derive the next data-row state. Puts + * are merged on top of the running state ({@code applyNew}); Deletes peel cells back out + * ({@link #applyDeleteToPut}). Returns {@code null} if there is no pre-image and the group never + * produces a Put, or if a Delete empties the row. Mirrors the single-row fold Phoenix performs on + * the active side when building {@code nextDataRowState}. + */ + @VisibleForTesting + static Put deriveNextState(Put preImage, List groupMutations) throws IOException { + Put nextState = preImage != null ? new Put(preImage) : null; + for (Mutation m : groupMutations) { + if (m instanceof Put) { + nextState = nextState != null ? applyNew((Put) m, nextState) : new Put((Put) m); + } else if (m instanceof Delete) { + nextState = applyDeleteToPut((Delete) m, nextState); + } + } + return nextState; + } + + /** + * True when this batch will be shipped to the replication log: replication is on, not disabled + * for testing, and an HA log group is present. 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). + */ + private boolean isReplicatedBatch(BatchMutateContext context) { + return shouldReplicate && !ignoreSyncReplicationForTesting && context.logGroup.isPresent(); + } + + /** + * Emit one pre-image cell per replicated row into {@code miniBatchOp.getWalEdit(0)}. This is the + * sole producer of pre-image cells for both replication paths: the synchronous + * {@link #replicateMutations} reads them back from the same WAL slot in POST, and the WAL-restore + * {@link #replicateEditOnWALRestore} reads them from the persisted WAL edit HBase builds from + * that slot — so both ship byte-identical pre-images with no re-derivation. + *

+ * We walk the batch (not {@code dataRowStates} directly) and skip {@code ignoreReplicationFilter} + * mutations, so a row filtered out of replication (e.g. syscat rows without a leading tenant id) + * gets no pre-image even though it may carry a {@code dataRowStates} entry. Each replicated row + * is emitted once, from its {@code dataRowStates} entry. + *

+ * Pre-image bytes are PB-encoded {@link Put}; an empty value is the sentinel for "primary + * observed an empty row at lock time" so the standby can distinguish that from "no primary + * information shipped". Rows not in {@code dataRowStates} (e.g. row not visited) contribute no + * pre-image cell — the standby falls back to its no-pre-image code path for those rows. + *

+ * Called from {@link #preBatchMutateWithExceptions} immediately after + * {@link #prepareDataRowStates} returns, on the global/uncovered/transform-index branch, and from + * {@link #captureLocalIndexPreImageCells} on the local-only replicated branch. + */ + private void capturePreImageCells(MiniBatchOperationInProgress miniBatchOp, + BatchMutateContext context) throws IOException { + if (context.dataRowStates == null || context.dataRowStates.isEmpty()) { + return; + } + WALEdit walEdit = miniBatchOp.getWalEdit(0); + if (walEdit == null) { + walEdit = new WALEdit(); + } + Set emitted = new HashSet<>(); + for (int i = 0; i < miniBatchOp.size(); i++) { + Mutation m = miniBatchOp.getOperation(i); + if (ignoreReplicationFilter.test(m)) { + continue; + } + ImmutableBytesPtr rowKeyPtr = new ImmutableBytesPtr(m.getRow()); + if (!emitted.add(rowKeyPtr)) { + continue; + } + Pair rowState = context.dataRowStates.get(rowKeyPtr); + if (rowState == null) { + continue; + } + walEdit.add(buildPreImageCell(rowKeyPtr.copyBytes(), rowState.getFirst())); + } + if (!walEdit.isEmpty()) { + miniBatchOp.setWalEdit(0, walEdit); + } + } + + /** + * Active-side pre-image capture for a local-only replicated table. Such a table has no + * global/uncovered/transform index, so it never enters the branch that runs + * {@link #prepareDataRowStates} + {@link #capturePreImageCells}; without this it would ship no + * pre-image and the standby's {@link PreImageLocalTable} would have nothing to read. The local + * index build already reads the prior row state through {@code cachedLocalTable} (a region scan + * scoped to index-relevant columns), so we reuse that exact state as the pre-image — no extra + * scan, and the same column scope the standby's local builder consumes. {@code dataRowStates} is + * populated with the prior row as {@code first} (a {@code null} prior row encodes to the + * empty-row sentinel) so {@link #capturePreImageCells} can emit one pre-image cell per replicated + * row. + */ + private void captureLocalIndexPreImageCells(MiniBatchOperationInProgress miniBatchOp, + BatchMutateContext context, Collection groupedMutations, + CachedLocalTable cachedLocalTable) throws IOException { + context.dataRowStates = new HashMap<>(groupedMutations.size()); + for (Mutation m : groupedMutations) { + List priorCells = + cachedLocalTable.getCurrentRowState(m, Collections. emptyList(), false); + Put priorState = null; + if (priorCells != null && !priorCells.isEmpty()) { + priorState = new Put(m.getRow()); + for (Cell cell : priorCells) { + priorState.add(cell); + } + } + context.dataRowStates.put(new ImmutableBytesPtr(m.getRow()), new Pair<>(priorState, null)); + } + capturePreImageCells(miniBatchOp, context); + } + /** * The index update generation for local indexes uses the existing index update generation code * (i.e., the {@link IndexBuilder} implementation). */ private void handleLocalIndexUpdates(TableName table, MiniBatchOperationInProgress miniBatchOp, - Collection pendingMutations, PhoenixIndexMetaData indexMetaData) - throws Throwable { + Collection pendingMutations, PhoenixIndexMetaData indexMetaData, + LocalHBaseState localHBaseState) throws Throwable { ListMultimap> indexUpdates = ArrayListMultimap.> create(); - this.builder.getIndexUpdates(indexUpdates, miniBatchOp, pendingMutations, indexMetaData); + if (localHBaseState != null) { + // Caller supplied the prior-row-state source: the standby replay passes a PreImageLocalTable + // (prior state from the shipped PRE_IMAGE, not a region scan), and the active local-only + // replicated path passes the CachedLocalTable it already built (so the pre-image capture and + // the index build share one scan). + this.builder.getIndexUpdates(indexUpdates, miniBatchOp, pendingMutations, indexMetaData, + localHBaseState); + } else { + this.builder.getIndexUpdates(indexUpdates, miniBatchOp, pendingMutations, indexMetaData); + } byte[] tableName = table.getName(); HTableInterfaceReference hTableInterfaceReference = new HTableInterfaceReference(new ImmutableBytesPtr(tableName)); @@ -1558,23 +2020,9 @@ public static void generateIndexMutationsForRow(ImmutableBytesPtr rowKeyPtr, * previous data row state with the pending row mutation. */ private void prepareIndexMutations(BatchMutateContext context, List maintainers, - long ts) throws IOException { + long batchTimestamp) throws IOException { List> indexTables = - new ArrayList<>(maintainers.size()); - for (IndexMaintainer indexMaintainer : maintainers) { - if (indexMaintainer.isLocalIndex()) { - continue; - } - if ( - !serializeCDCMutations && indexMaintainer.getIndexConsistency() != null - && indexMaintainer.getIndexConsistency().isAsynchronous() - ) { - continue; - } - HTableInterfaceReference hTableInterfaceReference = - new HTableInterfaceReference(new ImmutableBytesPtr(indexMaintainer.getIndexTableName())); - indexTables.add(new Pair<>(indexMaintainer, hTableInterfaceReference)); - } + buildIndexTablesList(maintainers); for (Map.Entry> entry : context.dataRowStates.entrySet()) { ImmutableBytesPtr rowKeyPtr = entry.getKey(); Pair dataRowState = entry.getValue(); @@ -1584,7 +2032,7 @@ private void prepareIndexMutations(BatchMutateContext context, List idxUpdates = ArrayListMultimap.create(); - generateIndexMutationsForRow(rowKeyPtr, currentDataRowState, nextDataRowState, ts, + generateIndexMutationsForRow(rowKeyPtr, currentDataRowState, nextDataRowState, batchTimestamp, encodedRegionName, QueryConstants.UNVERIFIED_BYTES, indexTables, idxUpdates); for (Map.Entry idxUpdate : idxUpdates.entries()) { context.indexUpdates.put(idxUpdate.getKey(), @@ -1593,6 +2041,27 @@ private void prepareIndexMutations(BatchMutateContext context, List> + buildIndexTablesList(List maintainers) { + List> indexTables = + new ArrayList<>(maintainers.size()); + for (IndexMaintainer indexMaintainer : maintainers) { + if (indexMaintainer.isLocalIndex()) { + continue; + } + if ( + !serializeCDCMutations && indexMaintainer.getIndexConsistency() != null + && indexMaintainer.getIndexConsistency().isAsynchronous() + ) { + continue; + } + HTableInterfaceReference hTableInterfaceReference = + new HTableInterfaceReference(new ImmutableBytesPtr(indexMaintainer.getIndexTableName())); + indexTables.add(new Pair<>(indexMaintainer, hTableInterfaceReference)); + } + return indexTables; + } + /** * This method prepares unverified index mutations which are applied to index tables before the * data table is updated. In the three-phase update approach, in phase 1, the status of existing @@ -1601,8 +2070,9 @@ private void prepareIndexMutations(BatchMutateContext context, List miniBatchOp, + BatchMutateContext context, long batchTimestamp, PhoenixIndexMetaData indexMetaData) + throws Throwable { List maintainers = indexMetaData.getIndexMaintainers(); // get the current span, or just use a null-span to avoid a bunch of if statements try (TraceScope scope = Trace.startSpan("Starting to build index updates")) { @@ -1614,11 +2084,17 @@ private void preparePreIndexMutations(BatchMutateContext context, long batchTime // The rest of this method is for handling global index updates context.indexUpdates = ArrayListMultimap.> create(); - prepareIndexMutations(context, maintainers, batchTimestamp); + if (context.isReplication) { + // Replicated batches carry per-row pre-images and per-cell timestamps from the active. + // Group by (row, ts) so each active-side batch's mutations are processed against their own + // pre-image — recovers the active-batch boundary the reader's coalescing can erase. + prepareReplicatedIndexMutations(miniBatchOp, context, maintainers); + } else { + prepareIndexMutations(context, maintainers, batchTimestamp); + } if (serializeCDCMutations) { - prepareEventuallyConsistentIndexMutations(context, batchTimestamp, maintainers, - compressCDCMutations); + prepareEventuallyConsistentIndexMutations(context, maintainers, compressCDCMutations); } context.preIndexUpdates = ArrayListMultimap. create(); @@ -1638,14 +2114,14 @@ private void preparePreIndexMutations(BatchMutateContext context, long batchTime List> updates = context.indexUpdates.get(hTableInterfaceReference); for (Pair update : updates) { Mutation m = update.getFirst(); + long ts = IndexUtil.getMaxTimestamp(m); + RowTsKey cdcKey = new RowTsKey(new ImmutableBytesPtr(update.getSecond()), ts); if (m instanceof Put) { if (indexMaintainer.isCDCIndex() && context.cdcPreMutationsBytes != null) { - ImmutableBytesPtr dataRowKeyPtr = new ImmutableBytesPtr(update.getSecond()); - byte[] cdcMutationsBytes = context.cdcPreMutationsBytes.get(dataRowKeyPtr); + byte[] cdcMutationsBytes = context.cdcPreMutationsBytes.get(cdcKey); if (cdcMutationsBytes != null) { ((Put) m).addColumn(QueryConstants.DEFAULT_COLUMN_FAMILY_BYTES, - QueryConstants.CDC_INDEX_PRE_MUTATIONS_CQ_BYTES, batchTimestamp, - cdcMutationsBytes); + QueryConstants.CDC_INDEX_PRE_MUTATIONS_CQ_BYTES, ts, cdcMutationsBytes); } } // This will be done before the data table row is updated (i.e., in the first write @@ -1656,15 +2132,12 @@ private void preparePreIndexMutations(BatchMutateContext context, long batchTime // row unverified again. Only do this for DeleteFamily // Set the status of the index row to "unverified" Put unverifiedPut = new Put(m.getRow()); - unverifiedPut.addColumn(emptyCF, emptyCQ, batchTimestamp, - QueryConstants.UNVERIFIED_BYTES); + unverifiedPut.addColumn(emptyCF, emptyCQ, ts, QueryConstants.UNVERIFIED_BYTES); if (indexMaintainer.isCDCIndex() && context.cdcPreMutationsBytes != null) { - ImmutableBytesPtr dataRowKeyPtr = new ImmutableBytesPtr(update.getSecond()); - byte[] cdcMutationsBytes = context.cdcPreMutationsBytes.get(dataRowKeyPtr); + byte[] cdcMutationsBytes = context.cdcPreMutationsBytes.get(cdcKey); if (cdcMutationsBytes != null) { unverifiedPut.addColumn(QueryConstants.DEFAULT_COLUMN_FAMILY_BYTES, - QueryConstants.CDC_INDEX_PRE_MUTATIONS_CQ_BYTES, batchTimestamp, - cdcMutationsBytes); + QueryConstants.CDC_INDEX_PRE_MUTATIONS_CQ_BYTES, ts, cdcMutationsBytes); } } // This will be done before the data table row is updated (i.e., in the first write @@ -1678,21 +2151,21 @@ private void preparePreIndexMutations(BatchMutateContext context, long batchTime } /** - * Prepares pre-phase and post-phase cdc mutations for eventually consistent indexes. + * Prepares pre-phase and post-phase cdc mutations for eventually consistent indexes. Each + * resulting builder/bytes entry is keyed by {@code (dataRowKey, ts)} where ts is read from the + * index Mutation's own cells (already stamped by {@link #generateIndexMutationsForRow}). On the + * active path each row produces one entry (ts == batchTimestamp); on the standby's per-(row, ts) + * grouping, two batches for the same row produce two entries. * @param context batch mutate context. - * @param batchTimestamp the timestamp to use for mutations. * @param maintainers the list of index maintainers. * @param compressMutations whether to Snappy-compress the serialized proto bytes. * @throws IOException if there is an error. */ private static void prepareEventuallyConsistentIndexMutations(BatchMutateContext context, - long batchTimestamp, List maintainers, boolean compressMutations) - throws IOException { - // Store pre-index and post-index mutations for each data table rowkey - Map preBuilderMap = - new HashMap<>(); - Map postBuilderMap = - new HashMap<>(); + List maintainers, boolean compressMutations) throws IOException { + // Store pre-index and post-index mutations per (data row, ts) group. + Map preBuilderMap = new HashMap<>(); + Map postBuilderMap = new HashMap<>(); for (IndexMaintainer indexMaintainer : maintainers) { if ( @@ -1709,11 +2182,12 @@ private static void prepareEventuallyConsistentIndexMutations(BatchMutateContext for (Pair update : updates) { Mutation m = update.getFirst(); byte[] dataRowKey = update.getSecond(); - ImmutableBytesPtr rowKeyPtr = new ImmutableBytesPtr(dataRowKey); - IndexMutationsProtos.IndexMutations.Builder preBuilder = preBuilderMap - .computeIfAbsent(rowKeyPtr, k -> IndexMutationsProtos.IndexMutations.newBuilder()); + long ts = IndexUtil.getMaxTimestamp(m); + RowTsKey key = new RowTsKey(new ImmutableBytesPtr(dataRowKey), ts); + IndexMutationsProtos.IndexMutations.Builder preBuilder = + preBuilderMap.computeIfAbsent(key, k -> IndexMutationsProtos.IndexMutations.newBuilder()); IndexMutationsProtos.IndexMutations.Builder postBuilder = postBuilderMap - .computeIfAbsent(rowKeyPtr, k -> IndexMutationsProtos.IndexMutations.newBuilder()); + .computeIfAbsent(key, k -> IndexMutationsProtos.IndexMutations.newBuilder()); if (m instanceof Put) { preBuilder.addTables(ByteString.copyFrom(indexMaintainer.getIndexTableName())); byte[] preMutation = @@ -1721,7 +2195,7 @@ private static void prepareEventuallyConsistentIndexMutations(BatchMutateContext preBuilder.addMutations(ByteString.copyFrom(preMutation)); if (!indexMaintainer.isUncovered()) { Put verifiedPut = new Put(m.getRow()); - verifiedPut.addColumn(emptyCF, emptyCQ, batchTimestamp, QueryConstants.VERIFIED_BYTES); + verifiedPut.addColumn(emptyCF, emptyCQ, ts, QueryConstants.VERIFIED_BYTES); postBuilder.addTables(ByteString.copyFrom(indexMaintainer.getIndexTableName())); byte[] postMutation = ProtobufUtil .toMutation(ClientProtos.MutationProto.MutationType.PUT, verifiedPut).toByteArray(); @@ -1730,8 +2204,7 @@ private static void prepareEventuallyConsistentIndexMutations(BatchMutateContext } else { if (IndexUtil.isDeleteFamily(m)) { Put unverifiedPut = new Put(m.getRow()); - unverifiedPut.addColumn(emptyCF, emptyCQ, batchTimestamp, - QueryConstants.UNVERIFIED_BYTES); + unverifiedPut.addColumn(emptyCF, emptyCQ, ts, QueryConstants.UNVERIFIED_BYTES); preBuilder.addTables(ByteString.copyFrom(indexMaintainer.getIndexTableName())); byte[] preMutation = ProtobufUtil .toMutation(ClientProtos.MutationProto.MutationType.PUT, unverifiedPut).toByteArray(); @@ -1747,9 +2220,9 @@ private static void prepareEventuallyConsistentIndexMutations(BatchMutateContext if (!preBuilderMap.isEmpty()) { context.cdcPreMutationsBytes = new HashMap<>(); - for (Map.Entry entry : preBuilderMap.entrySet()) { - ImmutableBytesPtr rowKey = entry.getKey(); + for (Map.Entry entry : preBuilderMap + .entrySet()) { + RowTsKey key = entry.getKey(); IndexMutationsProtos.IndexMutations.Builder builder = entry.getValue(); if (builder.getTablesCount() != builder.getMutationsCount()) { throw new DoNotRetryIOException( @@ -1758,7 +2231,7 @@ private static void prepareEventuallyConsistentIndexMutations(BatchMutateContext } if (builder.getTablesCount() > 0) { byte[] protoBytes = builder.build().toByteArray(); - context.cdcPreMutationsBytes.put(rowKey, + context.cdcPreMutationsBytes.put(key, compressMutations ? Snappy.compress(protoBytes) : protoBytes); } } @@ -1766,9 +2239,9 @@ private static void prepareEventuallyConsistentIndexMutations(BatchMutateContext if (!postBuilderMap.isEmpty()) { context.cdcPostMutationsBytes = new HashMap<>(); - for (Map.Entry entry : postBuilderMap.entrySet()) { - ImmutableBytesPtr rowKey = entry.getKey(); + for (Map.Entry entry : postBuilderMap + .entrySet()) { + RowTsKey key = entry.getKey(); IndexMutationsProtos.IndexMutations.Builder builder = entry.getValue(); if (builder.getTablesCount() != builder.getMutationsCount()) { throw new DoNotRetryIOException( @@ -1777,7 +2250,7 @@ private static void prepareEventuallyConsistentIndexMutations(BatchMutateContext } if (builder.getTablesCount() > 0) { byte[] protoBytes = builder.build().toByteArray(); - context.cdcPostMutationsBytes.put(rowKey, + context.cdcPostMutationsBytes.put(key, compressMutations ? Snappy.compress(protoBytes) : protoBytes); } } @@ -1797,7 +2270,7 @@ protected PhoenixIndexMetaData getPhoenixIndexMetaData( return (PhoenixIndexMetaData) indexMetaData; } - private void preparePostIndexMutations(BatchMutateContext context, long batchTimestamp, + private void preparePostIndexMutations(BatchMutateContext context, PhoenixIndexMetaData indexMetaData) { context.postIndexUpdates = ArrayListMultimap. create(); List maintainers = indexMetaData.getIndexMaintainers(); @@ -1819,7 +2292,8 @@ private void preparePostIndexMutations(BatchMutateContext context, long batchTim if (!indexMaintainer.isUncovered()) { Put verifiedPut = new Put(m.getRow()); // Set the status of the index row to "verified" - verifiedPut.addColumn(emptyCF, emptyCQ, batchTimestamp, QueryConstants.VERIFIED_BYTES); + verifiedPut.addColumn(emptyCF, emptyCQ, IndexUtil.getMaxTimestamp(m), + QueryConstants.VERIFIED_BYTES); context.postIndexUpdates.put(hTableInterfaceReference, verifiedPut); } } else { @@ -1839,13 +2313,13 @@ private void preparePostIndexMutations(BatchMutateContext context, long batchTim for (Pair update : updates) { Mutation m = update.getFirst(); if (m instanceof Put) { - ImmutableBytesPtr dataRowKeyPtr = new ImmutableBytesPtr(update.getSecond()); - byte[] cdcMutationsBytes = context.cdcPostMutationsBytes.get(dataRowKeyPtr); + long ts = IndexUtil.getMaxTimestamp(m); + RowTsKey cdcKey = new RowTsKey(new ImmutableBytesPtr(update.getSecond()), ts); + byte[] cdcMutationsBytes = context.cdcPostMutationsBytes.get(cdcKey); if (cdcMutationsBytes != null) { Put postPut = new Put(m.getRow()); postPut.addColumn(QueryConstants.DEFAULT_COLUMN_FAMILY_BYTES, - QueryConstants.CDC_INDEX_POST_MUTATIONS_CQ_BYTES, batchTimestamp, - cdcMutationsBytes); + QueryConstants.CDC_INDEX_POST_MUTATIONS_CQ_BYTES, ts, cdcMutationsBytes); context.postIndexUpdates.put(hTableInterfaceReference, postPut); } } @@ -2042,6 +2516,16 @@ public void preBatchMutateWithExceptions(ObserverContext mutations = groupMutations(miniBatchOp, context); - handleLocalIndexUpdates(table, miniBatchOp, mutations, indexMetaData); + // dataRowStates is populated only by the global/uncovered/transform/atomic/... branch, so a + // null map here means this table has only a local index and never ran the global-style + // pre-image capture. Combined with a replicated batch, that identifies a replicated + // local-only table, which must ship a pre-image so the standby can regenerate its local + // index. Build the prior-state scan once and reuse it for both the pre-image capture and + // the index build. Mixed tables already captured a (superset) pre-image in the earlier + // branch; non-replicated local-only tables keep the unchanged path (no extra work). + if (context.dataRowStates == null && isReplicatedBatch(context)) { + CachedLocalTable cachedLocalTable = + CachedLocalTable.build(mutations, indexMetaData, c.getEnvironment().getRegion()); + captureLocalIndexPreImageCells(miniBatchOp, context, mutations, cachedLocalTable); + handleLocalIndexUpdates(table, miniBatchOp, mutations, indexMetaData, cachedLocalTable); + } else { + handleLocalIndexUpdates(table, miniBatchOp, mutations, indexMetaData, null); + } } if (failDataTableUpdatesForTesting) { throw new DoNotRetryIOException("Simulating the data table write failure"); @@ -2145,46 +2644,67 @@ && isPartialUncoveredIndexMutation(indexMetaData, miniBatchOp) } /** - * We need to add the index mutations to the data table's WAL to handle cases where the RS crashes - * before the postBatchMutateIndispensably hook is called where the mutations are synchronously - * replicated. This is needed because during WAL restore we don't have the IndexMaintainer object - * to generate the corresponding index mutations. + * Standby replay path for {@link #preBatchMutateWithExceptions}. Reached only for replicated + * batches (batches whose mutations carry the {@link #REPLICATED_MUTATION} marker), after the + * shared prologue has locked the rows and set the PRE phase. Deliberately runs none of the + * active-only steps: no timestamp assignment (cells already carry the active's final per-cell + * timestamps), no data-table row-state scan (index updates are regenerated from the per-row + * PRE_IMAGE the active shipped), no replication capture (a replayed batch is not re-replicated), + * and no concurrent-batch wait (each replicated batch is self-sufficient via its PRE_IMAGE, so + * concurrent standby batches on the same row need no ordering). */ - private void addGlobalIndexMutationsToWAL(MiniBatchOperationInProgress miniBatchOp, - BatchMutateContext context) { - if (!this.shouldReplicate) { - return; + private void preBatchMutateReplication(ObserverContext c, + MiniBatchOperationInProgress miniBatchOp, BatchMutateContext context, + PhoenixIndexMetaData indexMetaData) throws Throwable { + TableName table = c.getEnvironment().getRegion().getRegionInfo().getTable(); + if (context.hasGlobalIndex || context.hasUncoveredIndex || context.hasTransform) { + // batchTimestamp is unused on this path: prepareReplicatedIndexMutations derives each index + // update's timestamp from its (row, ts) group, not from a batch-wide timestamp. + prepareAndCommitGlobalIndexUpdates(table, miniBatchOp, context, 0, indexMetaData); } - - WALEdit edit = miniBatchOp.getWalEdit(0); - if (edit == null) { - edit = new WALEdit(); - miniBatchOp.setWalEdit(0, edit); + if (context.hasLocalIndex) { + // Group by (row, ts) so each replayed active-side batch's cells stay in their own uniform-ts + // mutation for NonTxIndexBuilder, and serve the builder's prior row state from each group's + // shipped PRE_IMAGE instead of a (not-yet-written) region scan. + List groups = getReplicatedRowGroups(miniBatchOp, context); + Map> preImageCellsByRowTs = new HashMap<>(); + Collection mutations = + buildReplayLocalIndexInputs(groups, preImageCellsByRowTs); + handleLocalIndexUpdates(table, miniBatchOp, mutations, indexMetaData, + new PreImageLocalTable(dataTableName, preImageCellsByRowTs)); } - - if (context.preIndexUpdates != null) { - for (Map.Entry entry : context.preIndexUpdates - .entries()) { - if (this.ignoreReplicationFilter.test(entry.getValue())) { - continue; - } - // This creates cells of family type WALEdit.METAFAMILY which are not applied - // on restore - edit.add(IndexedKeyValue.newIndexedKeyValue(entry.getKey().get(), entry.getValue())); - } + if (failDataTableUpdatesForTesting) { + throw new DoNotRetryIOException("Simulating the data table write failure"); } + } - if (context.postIndexUpdates != null) { - for (Map.Entry entry : context.postIndexUpdates - .entries()) { - if (this.ignoreReplicationFilter.test(entry.getValue())) { - continue; - } - // This creates cells of family type WALEdit.METAFAMILY which are not applied - // on restore - edit.add(IndexedKeyValue.newIndexedKeyValue(entry.getKey().get(), entry.getValue())); - } + /** + * Shared two-phase index-commit sequence for the global/uncovered/transform branch, run + * identically on the active and standby paths. The two paths differ only in how + * {@link #preparePreIndexMutations} fills {@code context.indexUpdates} (computed from + * {@code dataRowStates} on the active, regenerated from the per-row PRE_IMAGE on the standby); + * from there the prepare -> unlock -> doPre -> lock -> wait -> post protocol is the same. + */ + private void prepareAndCommitGlobalIndexUpdates(TableName table, + MiniBatchOperationInProgress miniBatchOp, BatchMutateContext context, + long batchTimestamp, PhoenixIndexMetaData indexMetaData) throws Throwable { + // early exit if it turns out we don't have any edits + long start = EnvironmentEdgeManager.currentTimeMillis(); + preparePreIndexMutations(miniBatchOp, context, batchTimestamp, indexMetaData); + metricSource.updateIndexPrepareTime(dataTableName, + EnvironmentEdgeManager.currentTimeMillis() - start); + // Release the locks before making RPC calls for index updates + unlockRows(context); + // Do the first phase index updates + doPre(context); + // Acquire the locks again before letting the region proceed with data table updates + lockRows(context); + // Only populated by getCurrentRowStates, which the standby skips, so this is always null on + // replication: each replicated batch is self-sufficient via its PRE_IMAGE and needs no wait. + if (context.lastConcurrentBatchContext != null) { + waitForPreviousConcurrentBatch(table, context); } + preparePostIndexMutations(context, indexMetaData); } /** @@ -2245,6 +2765,7 @@ public void preWALAppend(ObserverContext c, WALKey if (shouldReplicate) { BatchMutateContext context = getBatchMutateContext(c); appendHAGroupAttributeToWALKey(key, context); + appendReplicationAttributesToWALKey(key, context); } } @@ -2276,6 +2797,17 @@ private void appendHAGroupAttributeToWALKey(WALKey key, } } + private void appendReplicationAttributesToWALKey(WALKey key, + IndexRegionObserver.BatchMutateContext context) { + if (context == null || context.getOriginalMutations().isEmpty()) { + return; + } + Map replicationAttributes = buildReplicationAttributes(context); + for (Map.Entry e : replicationAttributes.entrySet()) { + IndexRegionObserver.appendToWALKey(key, e.getKey(), e.getValue()); + } + } + /** * When this hook is called, all the rows in the batch context are locked if the batch of * mutations is successful. Because the rows are locked, we can safely make updates to pending row @@ -2338,7 +2870,9 @@ public void postBatchMutateIndispensably(ObserverContext postIndexFuture = CompletableFuture.runAsync(() -> doPost(c, context)); - replicateMutations(c.getEnvironment(), miniBatchOp, context); + if (isReplicatedBatch(context)) { + replicateMutations(context.logGroup.get(), miniBatchOp, context); + } FutureUtils.get(postIndexFuture); } } finally { @@ -2892,73 +3426,88 @@ public static boolean isAtomicOperationComplete(OperationStatus status) { return status.getOperationStatusCode() == SUCCESS && status.getResult() != null; } - private void replicateMutations(RegionCoprocessorEnvironment env, + /** + * A cell crosses the replication wire iff it is data to replicate or our own pre-image marker. + * Local-index (L#) cells are dropped: the standby regenerates its local index from the data + * record, and a replicated L# rowkey would carry the active region's start key, meaningless on + * the standby whose regions are split and assigned independently. METAFAMILY cells are dropped + * unless they carry our {@link #PRE_IMAGE_WAL_QUALIFIER}, so a foreign coprocessor's WAL + * contribution (or any other HBase system marker) cannot leak onto the wire. Applied identically + * by the synchronous ({@link #replicateMutations}) and WAL-restore + * ({@link #replicateEditOnWALRestore}) paths so both enforce one wire invariant: data plus our + * pre-image only. + */ + private static boolean isReplicableCell(Cell c) { + if (CellUtil.matchingFamily(c, WALEdit.METAFAMILY)) { + return CellUtil.matchingQualifier(c, PRE_IMAGE_WAL_QUALIFIER); + } + return !MetaDataUtil.isLocalIndexFamily( + new ImmutableBytesPtr(c.getFamilyArray(), c.getFamilyOffset(), c.getFamilyLength())); + } + + private void replicateMutations(ReplicationLogGroup logGroup, MiniBatchOperationInProgress miniBatchOp, BatchMutateContext context) throws IOException { - - if (!this.shouldReplicate) { + // Replicated batches on the standby never re-replicate. + if (context.isReplication) { return; } - if (ignoreSyncReplicationForTesting) { + if (context.getOriginalMutations().isEmpty()) { return; } - - Optional logGroup = getHAGroupFromBatch(env, miniBatchOp); - if (!logGroup.isPresent()) { - return; - } - ReplicationLogGroup group = logGroup.get(); - - // Record ReplicationSyncTime only when we are actually doing work (not on early-return paths). - long start = EnvironmentEdgeManager.currentTimeMillis(); - try { - List dataTableCells = new ArrayList<>(); - for (int i = 0; i < miniBatchOp.size(); i++) { - Mutation m = miniBatchOp.getOperation(i); - if (this.ignoreReplicationFilter.test(m)) { - continue; + // Read the batch's now-final cells directly from miniBatchOp in POST. By this point HBase has + // finalized timestamps and merged every coprocessor-added cell (local index, on-dup, TTL) into + // the data mutation's family map (checkAndMergeCPMutations). Local-index (L#) cells are merged + // under their own L# family, so a single family-key check drops the whole list without touching + // each cell. The pre-image cells live in the WAL edit (slot 0), written by + // capturePreImageCells; + // they are filtered through isReplicableCell so a foreign coprocessor's slot-0 contribution + // cannot leak onto the wire. + List flattened = new ArrayList<>(); + for (int i = 0; i < miniBatchOp.size(); i++) { + Mutation m = miniBatchOp.getOperation(i); + if (ignoreReplicationFilter.test(m)) { + continue; + } + for (Map.Entry> entry : m.getFamilyCellMap().entrySet()) { + // Drop L# cells: the standby regenerates its own local index from the data record, and a + // replicated L# rowkey would carry the active region's start key, which is meaningless on + // the standby whose regions are split and assigned independently. + if (!MetaDataUtil.isLocalIndexFamily(entry.getKey())) { + flattened.addAll(entry.getValue()); } - // When coprocessors add cells (local index, conditional TTL, ON DUPLICATE KEY UPDATE), - // HBase merges them into the data mutation which can mix row keys and cell types. - // We stream the cells through as-is — the consumer reconstructs Put/Delete mutations - // on the row+type boundary. - appendCells(dataTableCells, m); - } - if (!dataTableCells.isEmpty()) { - group.append(this.dataTableName, -1, dataTableCells); - } - appendIndexUpdates(group, context.preIndexUpdates); - appendIndexUpdates(group, context.postIndexUpdates); - group.sync(); - } finally { - long duration = EnvironmentEdgeManager.currentTimeMillis() - start; - metricSource.updateReplicationSyncTime(this.dataTableName, duration); + } } - } - - private void appendIndexUpdates(ReplicationLogGroup group, - ListMultimap updates) throws IOException { - if (updates == null) { - return; + WALEdit preImageEdit = miniBatchOp.getWalEdit(0); + if (preImageEdit != null) { + preImageEdit.getCells().stream().filter(IndexRegionObserver::isReplicableCell) + .forEach(flattened::add); } - for (Map.Entry> entry : updates.asMap() - .entrySet()) { - List cells = new ArrayList<>(); - for (Mutation m : entry.getValue()) { - if (this.ignoreReplicationFilter.test(m)) { - continue; - } - appendCells(cells, m); - } - if (!cells.isEmpty()) { - group.append(entry.getKey().getTableName(), -1, cells); - } + if (flattened.isEmpty()) { + return; } + Map replicationAttributes = buildReplicationAttributes(context); + logGroup.append(dataTableName, -1, flattened, replicationAttributes); + logGroup.sync(); } - private static void appendCells(List bucket, Mutation m) { - for (List familyCells : m.getFamilyCellMap().values()) { - bucket.addAll(familyCells); + /** + * Build the replication attribute envelope shipped with a batch: the well-known metadata keys + * carried on the batch's mutations, plus an empty {@link PhoenixIndexCodec#INDEX_UUID} when (and + * only when) the table carries an index. An empty UUID forces the standby down the server-PTable + * resolution path (see {@link PhoenixIndexMetaDataBuilder}), which rebuilds index maintainers + * from the schema/table/tenant attributes in this same envelope. It is stamped only for indexed + * tables: a non-indexed table needs no regeneration, and an empty UUID there would push the + * standby into the server-cache branch and fail with INDEX_METADATA_NOT_FOUND. The active's own + * resolved index maintainers ({@link BatchMutateContext#hasIndex()}) are the source of truth, not + * the client-set UUID attribute. + */ + private static Map buildReplicationAttributes(BatchMutateContext context) { + Map replicationAttributes = + MutationCellGrouper.extractReplicationAttributes(context.getOriginalMutations().get(0)); + if (context.hasIndex()) { + MutationCellGrouper.stampIndexAttribute(replicationAttributes); } + return replicationAttributes; } } diff --git a/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/builder/IndexBuildManager.java b/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/builder/IndexBuildManager.java index 53a86447342..b512016c823 100644 --- a/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/builder/IndexBuildManager.java +++ b/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/builder/IndexBuildManager.java @@ -33,6 +33,7 @@ import org.apache.phoenix.coprocessorclient.BaseScannerRegionObserverConstants.ReplayWrite; import org.apache.phoenix.hbase.index.covered.IndexMetaData; import org.apache.phoenix.hbase.index.covered.data.CachedLocalTable; +import org.apache.phoenix.hbase.index.covered.data.LocalHBaseState; import org.apache.phoenix.hbase.index.table.HTableInterfaceReference; import org.apache.phoenix.hbase.index.util.ImmutableBytesPtr; import org.apache.phoenix.index.PhoenixIndexMetaData; @@ -92,14 +93,28 @@ public void getIndexUpdates( ListMultimap> indexUpdates, MiniBatchOperationInProgress miniBatchOp, Collection mutations, IndexMetaData indexMetaData) throws Throwable { - // notify the delegate that we have started processing a batch - this.delegate.batchStarted(miniBatchOp, indexMetaData); CachedLocalTable cachedLocalTable = CachedLocalTable.build(mutations, (PhoenixIndexMetaData) indexMetaData, this.regionCoprocessorEnvironment.getRegion()); + getIndexUpdates(indexUpdates, miniBatchOp, mutations, indexMetaData, cachedLocalTable); + } + + /** + * Variant of + * {@link #getIndexUpdates(ListMultimap, MiniBatchOperationInProgress, Collection, IndexMetaData)} + * that uses a caller-supplied {@link LocalHBaseState} for prior-row-state lookups instead of + * building a region-scanning {@link CachedLocalTable}. Used on the standby replay path, where + * prior state comes from the per-batch pre-image rather than the (not-yet-written) region. + */ + public void getIndexUpdates( + ListMultimap> indexUpdates, + MiniBatchOperationInProgress miniBatchOp, Collection mutations, + IndexMetaData indexMetaData, LocalHBaseState localHBaseState) throws Throwable { + // notify the delegate that we have started processing a batch + this.delegate.batchStarted(miniBatchOp, indexMetaData); // Avoid the Object overhead of the executor when it's not actually parallelizing anything. for (Mutation m : mutations) { Collection> updates = - delegate.getIndexUpdate(m, indexMetaData, cachedLocalTable); + delegate.getIndexUpdate(m, indexMetaData, localHBaseState); for (Pair update : updates) { indexUpdates.put(new HTableInterfaceReference(new ImmutableBytesPtr(update.getSecond())), new Pair<>(update.getFirst(), m.getRow())); diff --git a/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/covered/data/PreImageLocalTable.java b/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/covered/data/PreImageLocalTable.java new file mode 100644 index 00000000000..ffb176e7295 --- /dev/null +++ b/phoenix-core-server/src/main/java/org/apache/phoenix/hbase/index/covered/data/PreImageLocalTable.java @@ -0,0 +1,97 @@ +/* + * 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.covered.data; + +import java.io.IOException; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.apache.hadoop.hbase.Cell; +import org.apache.hadoop.hbase.DoNotRetryIOException; +import org.apache.hadoop.hbase.client.Mutation; +import org.apache.phoenix.hbase.index.IndexRegionObserver.RowTsKey; +import org.apache.phoenix.hbase.index.covered.update.ColumnReference; +import org.apache.phoenix.hbase.index.util.ImmutableBytesPtr; +import org.apache.phoenix.util.IndexUtil; + +import org.apache.phoenix.thirdparty.com.google.common.base.Preconditions; + +/** + * Standby-side {@link LocalHBaseState} that serves the local-index builder's prior-row-state lookup + * from the per-batch pre-image the active shipped, instead of scanning the data-table region (which + * {@link CachedLocalTable} does on the active). + *

+ * On the standby the replication reader can concatenate several active-side batches into one replay + * batch, so a row recurs at several active timestamps and the region does not yet hold the + * intermediate state each batch saw. The active shipped one pre-image per row per batch, which is + * exactly the prior row state that batch built its local index against. We therefore key the lookup + * by {@code (row, ts)} — recovered from the mutation's row and max cell timestamp — and return that + * group's own pre-image cells. Each group's build is thus an independent reproduction of the + * corresponding active {@code preBatchMutate}; no chaining across groups is needed. + *

+ * A {@code null} cell list is the "active saw an empty row" sentinel and is the documented + * {@link LocalHBaseState#getCurrentRowState} return for "no prior row". A key that is absent + * entirely (never populated) is a contract violation and throws, so the sentinel stays unambiguous. + *

+ * Not thread-safe, and does not need to be: an instance is built per replay batch and consumed by + * that batch's single-threaded local-index build. The backing map is populated once at construction + * and never mutated afterward. + */ +public class PreImageLocalTable implements LocalHBaseState { + + /** Distinct marker for "key absent", so a stored null (empty-row sentinel) is not mistaken. */ + private static final List ABSENT = Collections.emptyList(); + + private final String dataTableName; + private final Map> preImageCellsByRowTs; + + public PreImageLocalTable(String dataTableName, Map> preImageCellsByRowTs) { + this.dataTableName = dataTableName; + this.preImageCellsByRowTs = + Preconditions.checkNotNull(preImageCellsByRowTs, "preImageCellsByRowTs must not be null"); + } + + /** + * {@inheritDoc} + *

+ * {@code toCover} and {@code ignoreNewerMutations} are ignored: we already hold the exact + * per-group prior-row snapshot the active built against, so there is nothing to narrow by column + * or filter by timestamp. The {@code (row, ts)} key is derived from the mutation the same way + * {@code IndexRegionObserver.buildReplicatedRowGroups} keys the map that populated this table. + */ + @Override + public List getCurrentRowState(Mutation mutation, + Collection toCover, boolean ignoreNewerMutations) + throws IOException { + RowTsKey key = + new RowTsKey(new ImmutableBytesPtr(mutation.getRow()), IndexUtil.getMaxTimestamp(mutation)); + // A stored null is the "active saw an empty row" sentinel, so a null value cannot be told from + // an absent key via get() alone; look up once against a distinct ABSENT marker instead. A true + // miss means the (row, ts) derivation drifted from the populating side + // (buildReplayLocalIndexInputs); fail loud rather than return null, which the builder would + // read + // as the empty-row sentinel and silently regenerate the index against an empty prior state. + List cells = preImageCellsByRowTs.getOrDefault(key, ABSENT); + if (cells == ABSENT) { + throw new DoNotRetryIOException("No pre-image for replayed local-index row on table " + + dataTableName + "; (row, ts) key not populated: " + key); + } + return cells; + } +} diff --git a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/MutationCellGrouper.java b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/MutationCellGrouper.java index d058bd192ab..210e32e63d2 100644 --- a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/MutationCellGrouper.java +++ b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/MutationCellGrouper.java @@ -19,12 +19,19 @@ import java.io.IOException; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import org.apache.hadoop.hbase.Cell; import org.apache.hadoop.hbase.CellUtil; +import org.apache.hadoop.hbase.HConstants; import org.apache.hadoop.hbase.client.Delete; import org.apache.hadoop.hbase.client.Mutation; import org.apache.hadoop.hbase.client.Put; +import org.apache.hadoop.hbase.wal.WALEdit; +import org.apache.phoenix.hbase.index.IndexRegionObserver; +import org.apache.phoenix.hbase.index.util.ImmutableBytesPtr; +import org.apache.phoenix.index.PhoenixIndexCodec; /** * Groups a flat cell stream into Put/Delete mutations, mirroring the algorithm HBase's @@ -37,6 +44,9 @@ * recurs non-consecutively yields a separate mutation per run), not correctness -- cell order is * preserved, so replaying the resulting mutations in order reproduces the effect of applying the * input cells in order. + *

+ * Stateless and thread-safe: a non-instantiable holder of static methods that operate solely on + * their arguments. */ public final class MutationCellGrouper { @@ -48,6 +58,110 @@ private static boolean isNewRowOrType(Cell previousCell, Cell cell) { || !CellUtil.matchingRows(previousCell, cell); } + /** + * Flatten a mutation's family cell map into a single ordered cell list, preserving family + * iteration order (typically TreeMap-ordered). + */ + public static List flattenCells(Mutation mutation) { + List body = new ArrayList<>(); + for (List familyCells : mutation.getFamilyCellMap().values()) { + body.addAll(familyCells); + } + return body; + } + + /** + * Extract the well-known replication attributes + * ({@link ReplicationLogGroup#REPLICATION_ATTR_KEYS}) from the mutation, copied verbatim. Returns + * an empty (mutable) map if the mutation has no attributes or none match. + *

+ * {@link PhoenixIndexCodec#INDEX_UUID} is deliberately NOT part of this envelope: whether the + * standby regenerates indexes is decided by the active from its resolved index maintainers, not + * from the client-set UUID attribute. The active stamps an empty UUID onto the returned map only + * for indexed tables (see {@code IndexRegionObserver}). + */ + public static Map extractReplicationAttributes(Mutation mutation) { + Map envelope = new HashMap<>(); + Map mutationAttrs = mutation.getAttributesMap(); + if (mutationAttrs == null || mutationAttrs.isEmpty()) { + return envelope; + } + for (String key : ReplicationLogGroup.REPLICATION_ATTR_KEYS) { + byte[] v = mutationAttrs.get(key); + if (v != null) { + envelope.put(key, v); + } + } + return envelope; + } + + /** + * Build the flat cell stream the active ships for a batch of replicated mutations: each + * mutation's data cells in family order, followed by one METAFAMILY pre-image cell carrying + * {@code preImage}'s row state. This is the inverse of {@link #reconstructMutations}; replaying + * its output through that method yields back the mutations with their + * {@link IndexRegionObserver#PRE_IMAGE} attribute attached. A {@code null} {@code preImage} + * encodes the empty-row sentinel. The pre-image cell is keyed by each mutation's own row, + * mirroring the active's per-row pre-image capture. + */ + public static List buildReplicatedCells(List mutations, Put preImage) + throws IOException { + List cells = new ArrayList<>(); + for (Mutation m : mutations) { + cells.addAll(flattenCells(m)); + cells.add(IndexRegionObserver.buildPreImageCell(m.getRow(), preImage)); + } + return cells; + } + + /** + * Stamp an empty {@link PhoenixIndexCodec#INDEX_UUID} onto a replication attribute envelope. An + * empty UUID forces the standby down the server-PTable resolution path, which rebuilds index + * maintainers from the schema/table/tenant attributes in the same envelope. Callers apply this + * only for indexed tables (a non-indexed table needs no regeneration, and an empty UUID there + * would fail on the standby with INDEX_METADATA_NOT_FOUND). + */ + public static void stampIndexAttribute(Map attrs) { + attrs.put(PhoenixIndexCodec.INDEX_UUID, HConstants.EMPTY_BYTE_ARRAY); + } + + /** + * Walk the record body, peeling off METAFAMILY pre-image cells (one per row) into a row-keyed + * bucket and grouping the remaining data cells into Put/Delete mutations. Each result mutation is + * stamped with the replication attributes and the generic + * {@link IndexRegionObserver#REPLICATED_MUTATION} marker. When a pre-image entry exists for its + * row, the pre-image bytes are also attached as {@link IndexRegionObserver#PRE_IMAGE}. + */ + public static List reconstructMutations(Iterable cells, + Map replicationAttrs) throws IOException { + Map preImages = new HashMap<>(); + List dataCells = new ArrayList<>(); + for (Cell c : cells) { + if ( + CellUtil.matchingFamily(c, WALEdit.METAFAMILY) + && CellUtil.matchingQualifier(c, IndexRegionObserver.PRE_IMAGE_WAL_QUALIFIER) + ) { + preImages.put(new ImmutableBytesPtr(CellUtil.cloneRow(c)), CellUtil.cloneValue(c)); + } else { + dataCells.add(c); + } + } + List mutations = splitCellsIntoMutations(dataCells); + for (Mutation m : mutations) { + if (replicationAttrs != null) { + for (Map.Entry e : replicationAttrs.entrySet()) { + m.setAttribute(e.getKey(), e.getValue()); + } + } + m.setAttribute(IndexRegionObserver.REPLICATED_MUTATION, HConstants.EMPTY_BYTE_ARRAY); + byte[] preImageBytes = preImages.get(new ImmutableBytesPtr(m.getRow())); + if (preImageBytes != null) { + m.setAttribute(IndexRegionObserver.PRE_IMAGE, preImageBytes); + } + } + return mutations; + } + /** Group a cell stream into Put/Delete mutations using the row+type boundary algorithm. */ public static List splitCellsIntoMutations(Iterable cells) throws IOException { List result = new ArrayList<>(); 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 4dd4eb78257..9621966ed1e 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 @@ -310,7 +310,7 @@ private void replayCurrentBatch() throws IOException { LOG.info("Replaying {} unsynced records into new writer {}", currentBatch.size(), currentWriter); for (Record r : currentBatch) { - currentWriter.append(r.tableName, r.commitId, r.cells); + currentWriter.append(r.tableName, r.commitId, r.cells, r.attributes); } } @@ -350,7 +350,7 @@ private void apply(Action action) throws IOException { protected void append(Record r) throws IOException { final boolean[] blockSynced = { false }; apply(writer -> { - blockSynced[0] = writer.append(r.tableName, r.commitId, r.cells); + blockSynced[0] = writer.append(r.tableName, r.commitId, r.cells, r.attributes); }); // Add to current batch only after we succeed at appending currentBatch.add(r); 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 1065cd19915..9959ecc8aab 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 @@ -42,6 +42,8 @@ import java.net.URI; import java.net.URISyntaxException; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.EnumSet; import java.util.List; import java.util.Map; @@ -62,6 +64,7 @@ import org.apache.hadoop.hbase.Cell; import org.apache.hadoop.hbase.ServerName; import org.apache.hadoop.hbase.client.Mutation; +import org.apache.phoenix.execute.MutationState; import org.apache.phoenix.jdbc.HAGroupStoreManager; import org.apache.phoenix.jdbc.HAGroupStoreRecord; import org.apache.phoenix.jdbc.HAGroupStoreRecord.HAGroupState; @@ -71,6 +74,7 @@ import org.slf4j.LoggerFactory; import org.apache.phoenix.thirdparty.com.google.common.annotations.VisibleForTesting; +import org.apache.phoenix.thirdparty.com.google.common.base.Preconditions; import org.apache.phoenix.thirdparty.com.google.common.util.concurrent.ThreadFactoryBuilder; import org.apache.hbase.thirdparty.com.google.common.collect.ImmutableMap; @@ -181,6 +185,28 @@ public class ReplicationLogGroup { "phoenix.replication.log.group.shutdown.timeout.ms"; public static final long DEFAULT_REPLICATION_LOG_GROUP_SHUTDOWN_TIMEOUT_MS = 30_000L; + /** + * Batch-uniform mutation attribute keys that are carried as the envelope of a replication log + * record (and mirrored on the WAL key for the WAL-restore path). These attributes enable the + * standby cluster's IndexRegionObserver to generate index mutations from replicated data + * mutations. + *

+ * {@code REPLICATED_MUTATION} (the generic "originated from replication" marker) and + * {@code PRE_IMAGE} (per-row primary-side pre-image bytes) are intentionally NOT in this list. + * Both are reader-synthesized: the standby stamps {@code REPLICATED_MUTATION} on every + * reconstructed mutation, and {@code PRE_IMAGE} on those whose row had a pre-image cell. + *

+ * {@code INDEX_UUID} is also NOT in this list. It is not copied from the mutation: the active + * decides from its own resolved index maintainers whether the table is indexed and, only then, + * stamps an empty UUID onto the envelope (see {@code IndexRegionObserver}). Keying off the + * client-set attribute here would make the standby's index regeneration depend on client behavior + * rather than on whether the table actually has indexes. + */ + public static final List REPLICATION_ATTR_KEYS = Collections + .unmodifiableList(Arrays.asList(MutationState.MutationMetadataType.SCHEMA_NAME.toString(), + MutationState.MutationMetadataType.LOGICAL_TABLE_NAME.toString(), + MutationState.MutationMetadataType.TENANT_ID.toString())); + public static final String STANDBY_DIR = "in"; public static final String FALLBACK_DIR = "out"; @@ -331,18 +357,21 @@ public void setValues(int type, Record record, CompletableFuture syncFutur } /** - * Append payload carried through the ring buffer. Always carries a flat {@link List} of - * {@link Cell}s; the per-mutation public API extracts the cells before publishing. + * Disruptor payload for one append. Carries a flat cell stream plus the envelope attributes that + * apply uniformly to every reconstructed mutation. */ protected static class Record { public final String tableName; public final long commitId; public final List cells; + public final Map attributes; - public Record(String tableName, long commitId, List cells) { - this.tableName = tableName; + public Record(String tableName, long commitId, List cells, + Map attributes) { + this.tableName = Preconditions.checkNotNull(tableName, "tableName must not be null"); this.commitId = commitId; - this.cells = cells; + this.cells = Preconditions.checkNotNull(cells, "cells must not be null"); + this.attributes = Preconditions.checkNotNull(attributes, "attributes must not be null"); } } @@ -557,37 +586,40 @@ public void append(String tableName, long commitId, Mutation mutation) throws IO if (LOG.isTraceEnabled()) { LOG.trace("Append: table={}, commitId={}, mutation={}", tableName, commitId, mutation); } - List cells = new ArrayList<>(); - for (List familyCells : mutation.getFamilyCellMap().values()) { - cells.addAll(familyCells); - } + List cells = MutationCellGrouper.flattenCells(mutation); if (cells.isEmpty()) { throw new IllegalArgumentException("Cannot append a mutation with no cells"); } - publishDataEvent(new Record(tableName, commitId, cells)); + publish(new Record(tableName, commitId, cells, + MutationCellGrouper.extractReplicationAttributes(mutation))); } /** - * Append a coalesced batch of cells as a single record on the log. The cells must already be - * grouped by row+type so consumers can reconstruct Put/Delete mutations on the row+type boundary - * (see {@link MutationCellGrouper}). Behaves identically to - * {@link #append(String, long, Mutation)} with respect to backpressure and fail-stop. - * @param tableName The HBase table name shared by every cell in {@code cells}. - * @param commitId The commit identifier (e.g., SCN) for the batch. - * @param cells The flat ordered cell stream for one batch on one table. - * @throws IOException If the writer is closed or the ring buffer is full. + * Append a batch of cells to the log as a single record. Like + * {@link #append(String, long, Mutation)}, this method is non-blocking and returns quickly unless + * the ring buffer is full; the actual write happens asynchronously and is durable only after a + * subsequent {@link #sync()}. + * @param tableName The name of the HBase table the cells apply to. + * @param commitId The commit identifier (e.g., SCN) associated with the batch. + * @param cells The flat ordered cell stream forming the record body. Mutation reconstruction + * (grouping by row+type) is the consumer's responsibility. + * @param attributes Record-level attributes (envelope) that apply uniformly to every mutation + * reconstructed from {@code cells}. + * @throws IOException If the writer is closed or if the ring buffer is full. */ - public void append(String tableName, long commitId, List cells) throws IOException { + public void append(String tableName, long commitId, List cells, + Map attributes) throws IOException { if (cells == null || cells.isEmpty()) { throw new IllegalArgumentException("Cannot append a record with no cells"); } if (LOG.isTraceEnabled()) { - LOG.trace("Append: table={}, commitId={}, cells={}", tableName, commitId, cells.size()); + LOG.trace("Append: table={}, commitId={}, cellCount={}, attrCount={}", tableName, commitId, + cells.size(), attributes.size()); } - publishDataEvent(new Record(tableName, commitId, cells)); + publish(new Record(tableName, commitId, cells, attributes)); } - private void publishDataEvent(Record record) throws IOException { + private void publish(Record record) throws IOException { if (isClosed()) { throw new IOException("Closed"); } diff --git a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/log/LogFile.java b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/log/LogFile.java index 8e3e2ea25f2..7c236c7f2f1 100644 --- a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/log/LogFile.java +++ b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/log/LogFile.java @@ -378,6 +378,20 @@ interface Writer extends Closeable { */ boolean append(String tableName, long commitId, List cells) throws IOException; + /** + * Appends a batch of cells to the log file as a single record. The log record may be buffered + * internally. Mutation reconstruction (grouping by row+type) happens on the consumer side. + * @param tableName The HBase table name + * @param commitId The commit identifier + * @param cells The flat ordered cell stream forming the record body. + * @param attributes Record-level attributes (envelope) that apply uniformly to every mutation + * reconstructed from {@code cells}. + * @return true if an implicit sync happened (block full), false if buffered only + * @throws IOException if an I/O error occurs during append. + */ + boolean append(String tableName, long commitId, List cells, + Map attributes) throws IOException; + /** * Flushes any buffered data to the underlying storage and ensures it is durable (e.g., by * calling hsync on the FSDataOutputStream). This guarantees that records appended before the diff --git a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/log/LogFileRecord.java b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/log/LogFileRecord.java index e9c5b807b07..dee0ae66196 100644 --- a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/log/LogFileRecord.java +++ b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/log/LogFileRecord.java @@ -18,7 +18,6 @@ package org.apache.phoenix.replication.log; import java.io.IOException; -import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; @@ -89,15 +88,7 @@ public LogFile.Record setAttributes(Map attributes) { @Override public List getMutations() throws IOException { - List result = MutationCellGrouper.splitCellsIntoMutations(cells); - if (!attributes.isEmpty()) { - for (Mutation m : result) { - for (Map.Entry e : attributes.entrySet()) { - m.setAttribute(e.getKey(), e.getValue()); - } - } - } - return result; + return MutationCellGrouper.reconstructMutations(cells, attributes); } @Override @@ -112,12 +103,8 @@ public Mutation getMutation() throws IOException { @Override public LogFile.Record setMutation(Mutation mutation) { - List body = new ArrayList<>(); - for (List familyCells : mutation.getFamilyCellMap().values()) { - body.addAll(familyCells); - } - this.cells = body; - this.attributes = Collections.emptyMap(); + this.cells = MutationCellGrouper.flattenCells(mutation); + this.attributes = MutationCellGrouper.extractReplicationAttributes(mutation); return this; } diff --git a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/log/LogFileWriter.java b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/log/LogFileWriter.java index df3d4288648..3404fe0baa2 100644 --- a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/log/LogFileWriter.java +++ b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/log/LogFileWriter.java @@ -19,7 +19,9 @@ import java.io.FileNotFoundException; import java.io.IOException; +import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; import org.apache.hadoop.fs.FSDataOutputStream; import org.apache.hadoop.fs.FileSystem; @@ -81,11 +83,17 @@ public boolean isClosed() { @Override public boolean append(String tableName, long commitId, List cells) throws IOException { + return append(tableName, commitId, cells, Collections.emptyMap()); + } + + @Override + public boolean append(String tableName, long commitId, List cells, + Map attributes) throws IOException { if (isClosed()) { throw new IOException("Writer has been closed"); } - return writer.append( - new LogFileRecord().setHBaseTableName(tableName).setCommitId(commitId).setCells(cells)); + return writer.append(new LogFileRecord().setHBaseTableName(tableName).setCommitId(commitId) + .setCells(cells).setAttributes(attributes)); } @Override diff --git a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/reader/ReplicationLogProcessor.java b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/reader/ReplicationLogProcessor.java index d9e488394cb..414bd1bb63a 100644 --- a/phoenix-core-server/src/main/java/org/apache/phoenix/replication/reader/ReplicationLogProcessor.java +++ b/phoenix-core-server/src/main/java/org/apache/phoenix/replication/reader/ReplicationLogProcessor.java @@ -33,6 +33,7 @@ import org.apache.hadoop.fs.FileSystem; import org.apache.hadoop.fs.LeaseRecoverable; import org.apache.hadoop.fs.Path; +import org.apache.hadoop.hbase.DoNotRetryIOException; import org.apache.hadoop.hbase.HBaseConfiguration; import org.apache.hadoop.hbase.HConstants; import org.apache.hadoop.hbase.TableName; @@ -246,22 +247,25 @@ public void processLogFile(FileSystem fs, Path filePath) throws IOException { for (LogFile.Record record : logFileReader) { final TableName tableName = TableName.valueOf(record.getHBaseTableName()); - // A record may reconstruct into multiple mutations. Batches split on the mutation - // boundary, so a single record's mutations can span two processReplicationLogBatch - // invocations -- do not assume per-record atomicity here. + // A record is a batch: its body is a flat cell stream that may contain METAFAMILY + // pre-image cells. getMutations() peels those pre-image cells off, stamps every + // reconstructed mutation with the generic REPLICATED_MUTATION marker, and attaches the + // per-row pre-image bytes as PRE_IMAGE on the owning row's mutation. The standby IRO + // uses PRE_IMAGE to populate dataRowStates without scanning the data table. + // All mutations of a record are added to the current batch together and the size check + // is deferred to the record boundary, so a record (and every (row, ts) group within it) + // is never split across two batches. for (Mutation mutation : record.getMutations()) { tableToMutationsMap.computeIfAbsent(tableName, k -> new ArrayList<>()).add(mutation); currentBatchSize++; currentBatchSizeBytes += mutation.heapSize(); - - // Process when we reach either the batch count or size limit - if (currentBatchSize >= getBatchSize() || currentBatchSizeBytes >= getBatchSizeBytes()) { - processReplicationLogBatch(tableToMutationsMap); - totalProcessed += currentBatchSize; - tableToMutationsMap.clear(); - currentBatchSize = 0; - currentBatchSizeBytes = 0; - } + } + if (currentBatchSize >= getBatchSize() || currentBatchSizeBytes >= getBatchSizeBytes()) { + processReplicationLogBatch(tableToMutationsMap); + totalProcessed += currentBatchSize; + tableToMutationsMap.clear(); + currentBatchSize = 0; + currentBatchSizeBytes = 0; } } @@ -402,6 +406,14 @@ protected void processReplicationLogBatch(Map> tableMu } attempt++; getMetrics().incrementFailedBatchCount(); + // A DoNotRetryIOException is deterministic (e.g. a schema/contract violation): retrying the + // same mutations will fail identically. Honor that contract as HBase's own retrying caller + // does and stop immediately rather than burning batchRetryCount attempts with backoff sleeps. + if (isNonRetryable(lastError)) { + LOG.error("Not retrying batch operations; failure is non-retryable. Failed tables: {}", + currentOperations.keySet()); + break; + } // Add delay between retries (exponential backoff) if (attempt <= batchRetryCount && !currentOperations.isEmpty()) { try { @@ -425,6 +437,21 @@ protected void processReplicationLogBatch(Map> tableMu } } + /** + * Returns true if the failure is a {@link DoNotRetryIOException}, walking the cause chain because + * the batch client wraps it (as a + * {@link org.apache.hadoop.hbase.client.RetriesExhaustedException} cause) by the time it reaches + * us. + */ + private static boolean isNonRetryable(Throwable error) { + for (Throwable cur = error; cur != null; cur = cur.getCause()) { + if (cur instanceof DoNotRetryIOException) { + return true; + } + } + return false; + } + /** * Calculates the delay time for retry attempts using exponential backoff. * @param attempt The current retry attempt number (0-based) @@ -469,7 +496,7 @@ protected ApplyMutationBatchResult applyMutations(Map> // Add failed mutations to retry list failedOperations.put(tableName, tableMutationMap.get(tableName)); getMetrics().incrementFailedMutationsCount(tableMutationMap.get(tableName).size()); - LOG.debug("Failed to apply mutations for table {}: {}", tableName, e.getMessage()); + LOG.debug("Failed to apply mutations for table {}", tableName, e); lastException = e; } } diff --git a/phoenix-core/src/it/java/org/apache/phoenix/hbase/index/write/TestTrackingParallelWriterIndexCommitter.java b/phoenix-core/src/it/java/org/apache/phoenix/hbase/index/write/TestTrackingParallelWriterIndexCommitter.java index d2867d8079c..25ab06ef456 100644 --- a/phoenix-core/src/it/java/org/apache/phoenix/hbase/index/write/TestTrackingParallelWriterIndexCommitter.java +++ b/phoenix-core/src/it/java/org/apache/phoenix/hbase/index/write/TestTrackingParallelWriterIndexCommitter.java @@ -39,26 +39,25 @@ public class TestTrackingParallelWriterIndexCommitter extends TrackingParallelWr public static class TestConnectionFactory extends ServerUtil.ConnectionFactory { - private static Map connections = - new ConcurrentHashMap(); + private static Map connections = + new ConcurrentHashMap(); public static Connection getConnection(final ServerUtil.ConnectionType connectionType, final RegionCoprocessorEnvironment env) { final String key = String.format("%s-%s", env.getServerName(), connectionType.name().toLowerCase()); LOGGER.info("Connecting to {}", key); - return connections.computeIfAbsent(connectionType, - new Function() { - @Override - public Connection apply(ServerUtil.ConnectionType t) { - try { - return env.createConnection( - getTypeSpecificConfiguration(connectionType, env.getConfiguration())); - } catch (IOException e) { - throw new RuntimeException(e); - } + return connections.computeIfAbsent(key, new Function() { + @Override + public Connection apply(String k) { + try { + return env.createConnection( + getTypeSpecificConfiguration(connectionType, env.getConfiguration())); + } catch (IOException e) { + throw new RuntimeException(e); } - }); + } + }); } public static void shutdown() { 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 new file mode 100644 index 00000000000..95df33a5d33 --- /dev/null +++ b/phoenix-core/src/it/java/org/apache/phoenix/replication/ReplicationLogGroupBaseIT.java @@ -0,0 +1,527 @@ +/* + * 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.replication; + +import static org.apache.phoenix.hbase.index.IndexRegionObserver.PHOENIX_INDEX_CDC_MUTATION_SERIALIZE; +import static org.apache.phoenix.jdbc.HighAvailabilityGroup.PHOENIX_HA_GROUP_ATTR; +import static org.apache.phoenix.jdbc.HighAvailabilityTestingUtility.getHighAvailibilityGroup; +import static org.apache.phoenix.jdbc.PhoenixDatabaseMetaData.SYSTEM_CATALOG_NAME; +import static org.apache.phoenix.query.QueryServices.SYNCHRONOUS_REPLICATION_ENABLED; +import static org.apache.phoenix.replication.ReplicationShardDirectoryManager.PHOENIX_REPLICATION_ROUND_DURATION_SECONDS_KEY; +import static org.apache.phoenix.replication.reader.ReplicationLogReplayService.PHOENIX_REPLICATION_REPLAY_ENABLED; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.io.IOException; +import java.sql.Connection; +import java.sql.DriverManager; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.apache.hadoop.fs.FileStatus; +import org.apache.hadoop.fs.FileSystem; +import org.apache.hadoop.fs.Path; +import org.apache.hadoop.hbase.Cell; +import org.apache.hadoop.hbase.CellUtil; +import org.apache.hadoop.hbase.HBaseTestingUtility; +import org.apache.hadoop.hbase.ServerName; +import org.apache.hadoop.hbase.TableName; +import org.apache.hadoop.hbase.client.ConnectionFactory; +import org.apache.hadoop.hbase.client.Mutation; +import org.apache.hadoop.hbase.client.RegionLocator; +import org.apache.hadoop.hbase.client.Result; +import org.apache.hadoop.hbase.client.ResultScanner; +import org.apache.hadoop.hbase.client.Scan; +import org.apache.hadoop.hbase.client.Table; +import org.apache.hadoop.hbase.regionserver.HRegionServer; +import org.apache.hadoop.hbase.util.Bytes; +import org.apache.hadoop.hbase.util.VersionInfo; +import org.apache.phoenix.expression.function.PartitionIdFunction; +import org.apache.phoenix.jdbc.HABaseIT; +import org.apache.phoenix.jdbc.HighAvailabilityGroup; +import org.apache.phoenix.jdbc.HighAvailabilityPolicy; +import org.apache.phoenix.jdbc.HighAvailabilityTestingUtility; +import org.apache.phoenix.jdbc.PhoenixDriver; +import org.apache.phoenix.query.QueryConstants; +import org.apache.phoenix.replication.reader.ReplicationLogProcessor; +import org.apache.phoenix.replication.tool.LogFileAnalyzer; +import org.apache.phoenix.util.TestUtil; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.Rule; +import org.junit.rules.TestName; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Shared fixture for the replication log-group ITs: cluster setup, per-test HA-group wiring, and + * the replay/verification helpers. Concrete subclasses supply their own {@code @BeforeClass} + * calling {@link #setupClusters()} so each runs exactly the tests it declares; nothing here is a + * {@code @Test}. + */ +public abstract class ReplicationLogGroupBaseIT extends HABaseIT { + protected static final Logger LOG = LoggerFactory.getLogger(ReplicationLogGroupBaseIT.class); + + @Rule + public TestName name = new TestName(); + + protected Properties clientProps = new Properties(); + protected String haGroupName; + protected HighAvailabilityGroup haGroup; + protected ReplicationLogGroup logGroup; + + /** + * Starts both clusters with the common replication test config. A subclass that needs a + * cluster-level toggle (e.g. {@code serializeCDCMutations}, read once at IRO {@code start()}) + * sets it on {@code conf1}/{@code conf2} in its own {@code @BeforeClass} before calling this. + */ + protected static void setupClusters() throws Exception { + conf1.setInt(PHOENIX_REPLICATION_ROUND_DURATION_SECONDS_KEY, 5); + // Disable replay on cluster 1 + conf1.setBoolean(PHOENIX_REPLICATION_REPLAY_ENABLED, false); + // Disable replay on cluster 2, we will explicitly replay the log files + conf2.setBoolean(PHOENIX_REPLICATION_REPLAY_ENABLED, false); + // Disable writer on cluster 2 + conf2.setBoolean(SYNCHRONOUS_REPLICATION_ENABLED, false); + CLUSTERS.start(); + DriverManager.registerDriver(PhoenixDriver.INSTANCE); + } + + @AfterClass + public static void tearDownAfterClass() throws Exception { + DriverManager.deregisterDriver(PhoenixDriver.INSTANCE); + CLUSTERS.close(); + } + + @Before + public void beforeTest() throws Exception { + LOG.info("Starting test {}", name.getMethodName()); + haGroupName = name.getMethodName(); + clientProps = HighAvailabilityTestingUtility.getHATestProperties(); + clientProps.setProperty(PHOENIX_HA_GROUP_ATTR, haGroupName); + CLUSTERS.initClusterRole(haGroupName, HighAvailabilityPolicy.FAILOVER); + haGroup = getHighAvailibilityGroup(CLUSTERS.getJdbcHAUrl(), clientProps); + LOG.info("Initialized haGroup {} with URL {}", haGroup, CLUSTERS.getJdbcHAUrl()); + logGroup = getReplicationLogGroup(); + } + + @After + public void afterTest() throws Exception { + LOG.info("Starting cleanup for test {}", name.getMethodName()); + logGroup.close(); + LOG.info("Ending cleanup for test {}", name.getMethodName()); + } + + private ReplicationLogGroup getReplicationLogGroup() throws IOException { + HRegionServer rs = CLUSTERS.getHBaseCluster1().getHBaseCluster().getRegionServer(0); + return ReplicationLogGroup.get(conf1, rs.getServerName(), haGroupName); + } + + protected Map> groupLogsByTable() throws Exception { + LogFileAnalyzer analyzer = new LogFileAnalyzer(); + // use peer cluster conf + analyzer.setConf(conf2); + Path standByLogDir = logGroup.getOrCreatePeerShardManager().getRootDirectoryPath(); + LOG.info("Analyzing log files at {}", standByLogDir); + String[] args = { "--check", standByLogDir.toString() }; + assertEquals(0, analyzer.run(args)); + return analyzer.groupLogsByTable(standByLogDir.toString()); + } + + protected int getCountForTable(Map> logsByTable, String tableName) + throws Exception { + List mutations = logsByTable.get(tableName); + return mutations != null ? mutations.size() : 0; + } + + protected Map countRecordsByTable() throws Exception { + LogFileAnalyzer analyzer = new LogFileAnalyzer(); + // use peer cluster conf + analyzer.setConf(conf2); + Path standByLogDir = logGroup.getOrCreatePeerShardManager().getRootDirectoryPath(); + return analyzer.countRecordsByTable(standByLogDir.toString()); + } + + /** + * Counts how many of the given log files actually carry records. A single RS writes one file per + * replication round and rotates at every round boundary, so a short workload leaves several empty + * round-files around the one that holds its mutations. Empty files contribute no work to replay, + * so this -- not the raw file count -- is the true measure of how many files a parallel replay + * overlaps on. Reads only record counts (no mutation expansion), so it is cheap. + */ + protected int countFilesWithRecords(List logFiles) throws Exception { + LogFileAnalyzer analyzer = new LogFileAnalyzer(); + analyzer.setConf(conf2); + int populated = 0; + for (Path logFile : logFiles) { + if (!analyzer.countRecordsByTable(logFile.toString()).isEmpty()) { + populated++; + } + } + return populated; + } + + protected void verifyReplication(Map expected) throws Exception { + // first close the logGroup + logGroup.close(); + Map> mutationsByTable = groupLogsByTable(); + dumpTableLogCount(mutationsByTable); + for (Map.Entry entry : expected.entrySet()) { + String tableName = entry.getKey(); + int expectedMutationCount = entry.getValue(); + List mutations = mutationsByTable.get(tableName); + int actualMutationCount = mutations != null ? mutations.size() : 0; + try { + if (!tableName.equals(SYSTEM_CATALOG_NAME)) { + assertEquals(String.format("For table %s", tableName), expectedMutationCount, + actualMutationCount); + } else { + // special handling for syscat + assertTrue("For SYSCAT", actualMutationCount >= expectedMutationCount); + } + } catch (AssertionError e) { + // create a regular connection + try (Connection conn = DriverManager.getConnection(CLUSTERS.getJdbcUrl1(haGroup))) { + TestUtil.dumpTable(conn, TableName.valueOf(tableName)); + throw e; + } + } + } + } + + protected void dumpTableLogCount(Map> mutationsByTable) { + LOG.info("Dump table log count for test {}", name.getMethodName()); + for (Map.Entry> table : mutationsByTable.entrySet()) { + LOG.info("#Log entries for {} = {}", table.getKey(), table.getValue().size()); + } + } + + protected void moveRegionToServer(TableName tableName, ServerName sn) throws Exception { + HBaseTestingUtility util = CLUSTERS.getHBaseCluster1(); + try (RegionLocator locator = util.getConnection().getRegionLocator(tableName)) { + String regEN = locator.getAllRegionLocations().get(0).getRegionInfo().getEncodedName(); + while (!sn.equals(locator.getAllRegionLocations().get(0).getServerName())) { + LOG.info("Moving region {} of table {} to server {}", regEN, tableName, sn); + util.getAdmin().move(Bytes.toBytes(regEN), sn); + Thread.sleep(100); + } + LOG.info("Moved region {} of table {} to server {}", regEN, tableName, sn); + } + } + + protected void replayAndVerifyAcrossClusters(List ddlStatements, String... tablesToVerify) + throws Exception { + replayAndVerifyAcrossClusters(1, ddlStatements, tablesToVerify); + } + + /** + * Creates the schema on cluster 2, replays the replication log, and asserts cross-cluster cell + * equality for each named table. With {@code parallelism == 1} the log files are replayed + * sequentially in the calling thread; with {@code parallelism > 1} they are sharded round-robin + * across that many threads, all sharing one {@link ReplicationLogProcessor}. This simulates + * multiple region servers draining shard files in parallel within the same replay round, driving + * overlapping same-row batches through the standby IRO concurrently. Replay order does not affect + * the result: each {@code (row, ts)} group is an independent reproduction of the active's + * pre-batch state. + */ + protected void replayAndVerifyAcrossClusters(int parallelism, List ddlStatements, + String... tablesToVerify) throws Exception { + Path standByLogDir = logGroup.getOrCreatePeerShardManager().getRootDirectoryPath(); + + // Quiesce the log group before reading: this stops rotation and synchronously closes the + // writers, so every file has a durable trailer before any reader recovers its lease. Replaying + // against a still-open group lets lease recovery fence a writer mid-rotation, costing it its + // trailer. close() is idempotent, so this is a no-op when verifyReplication already closed it. + logGroup.close(); + + // Create the same schema on cluster 2 + try (Connection conn2 = CLUSTERS.getCluster2Connection(haGroup)) { + for (String ddl : ddlStatements) { + conn2.createStatement().execute(ddl); + } + conn2.commit(); + } + + // Replay replication log on cluster 2 + FileSystem fs = standByLogDir.getFileSystem(conf2); + List logFiles = findLogFiles(standByLogDir, fs); + assertTrue("Should have at least one log file", !logFiles.isEmpty()); + ReplicationLogProcessor processor = ReplicationLogProcessor.get(conf2, haGroupName); + try { + if (parallelism <= 1) { + for (Path logFile : logFiles) { + LOG.info("Replaying log file: {}", logFile); + processor.processLogFile(fs, logFile); + } + } else { + // A single populated file would silently degrade parallel replay to the sequential case + // (empty round-boundary files do no work), so a test that asked for concurrency must have + // mutations spread across more than one file -- i.e. the workload spanned several rounds. + // Fail loudly rather than pass with weaker coverage. + int populated = countFilesWithRecords(logFiles); + assertTrue("Parallel replay requested but only " + populated + " of " + logFiles.size() + + " log file(s) carry records; the workload must span multiple rounds", populated > 1); + replayLogFilesInParallel(processor, fs, logFiles, parallelism); + } + } finally { + processor.close(); + } + + // Verify tables match across clusters at the HBase cell level + for (String table : tablesToVerify) { + assertTablesEqualAcrossClusters(table); + } + } + + /** + * Replays the given log files concurrently across {@code parallelism} threads sharing one + * processor ({@code processLogFile} is stateless apart from the lazily-built, double-checked + * {@code AsyncConnection}, so concurrent calls are safe). Surfaces the first thread's failure as + * an {@link AssertionError} so a replay error fails the test rather than being swallowed. + */ + private void replayLogFilesInParallel(ReplicationLogProcessor processor, FileSystem fs, + List logFiles, int parallelism) throws Exception { + int threads = Math.min(parallelism, logFiles.size()); + CountDownLatch doneSignal = new CountDownLatch(threads); + AtomicReference firstError = new AtomicReference<>(); + for (int t = 0; t < threads; t++) { + final int offset = t; + final int stride = threads; + Thread worker = new Thread(() -> { + try { + for (int i = offset; i < logFiles.size(); i += stride) { + Path logFile = logFiles.get(i); + LOG.info("Replaying log file (thread {}): {}", offset, logFile); + processor.processLogFile(fs, logFile); + } + } catch (Throwable e) { + firstError.compareAndSet(null, e); + } finally { + doneSignal.countDown(); + } + }, "replay-worker-" + t); + worker.start(); + } + assertTrue("Ran out of time waiting for parallel replay", + doneSignal.await(120, TimeUnit.SECONDS)); + if (firstError.get() != null) { + throw new AssertionError("A parallel replay thread failed", firstError.get()); + } + } + + /** + * True for HBase 2.4.18+, 2.5.9+, and 2.6.0+, where atomic upsert with {@code RETURNING *} + * correctly projects the returned row. Mirrors {@code OnDuplicateKey2IT}. + */ + protected boolean isSetCorrectResultEnabledOnHBase() { + String hbaseVersion = VersionInfo.getVersion(); + String[] versionArr = hbaseVersion.split("\\."); + int majorVersion = Integer.parseInt(versionArr[0]); + int minorVersion = Integer.parseInt(versionArr[1]); + int patchVersion = Integer.parseInt(versionArr[2].split("-")[0]); + if (majorVersion != 2) { + return majorVersion > 2; + } + if (minorVersion >= 6) { + return true; + } + if (minorVersion < 4) { + return false; + } + if (minorVersion == 4) { + return patchVersion >= 18; + } + return patchVersion >= 9; + } + + protected List findLogFiles(Path dir, FileSystem fs) throws IOException { + List files = new ArrayList<>(); + findLogFilesRecursive(dir, fs, files); + return files; + } + + private void findLogFilesRecursive(Path dir, FileSystem fs, List files) throws IOException { + if (!fs.exists(dir)) { + return; + } + for (FileStatus status : fs.listStatus(dir)) { + if (status.isDirectory()) { + findLogFilesRecursive(status.getPath(), fs, files); + } else if (status.getPath().getName().endsWith(".plog")) { + files.add(status.getPath()); + } + } + } + + /** + * Compares an HBase table between the two clusters using {@link Result#compareResults}. Scans + * both tables with all versions and asserts that every row matches at the cell level. + */ + protected void assertTablesEqualAcrossClusters(String hbaseTableName) throws Exception { + TableName tn = TableName.valueOf(hbaseTableName); + try ( + org.apache.hadoop.hbase.client.Connection hconn1 = ConnectionFactory.createConnection(conf1); + org.apache.hadoop.hbase.client.Connection hconn2 = ConnectionFactory.createConnection(conf2); + Table table1 = hconn1.getTable(tn); Table table2 = hconn2.getTable(tn)) { + + Scan scan = new Scan(); + scan.readAllVersions(); + + try (ResultScanner scanner1 = table1.getScanner(scan); + ResultScanner scanner2 = table2.getScanner(scan)) { + int rowCount = 0; + while (true) { + Result r1 = scanner1.next(); + Result r2 = scanner2.next(); + if (r1 == null && r2 == null) { + break; + } + assertNotNull( + String.format("Table %s: cluster 2 has fewer rows at row %d", hbaseTableName, rowCount), + r2); + assertNotNull( + String.format("Table %s: cluster 1 has fewer rows at row %d", hbaseTableName, rowCount), + r1); + try { + Result.compareResults(r1, r2, true); + } catch (Exception e) { + LOG.error("Table {} row {} mismatch. Dumping both tables:", hbaseTableName, rowCount); + LOG.error("--- Cluster 1 ---"); + TestUtil.dumpTable(table1); + LOG.error("--- Cluster 2 ---"); + TestUtil.dumpTable(table2); + fail(String.format("Table %s row %d mismatch: %s", hbaseTableName, rowCount, + e.getMessage())); + } + rowCount++; + } + LOG.info("Table {} matches across clusters: {} rows verified", hbaseTableName, rowCount); + } + } + } + + /** + * Cross-cluster equality for a CDC index physical table. A CDC index rowkey leads with + * {@code PARTITION_ID()} = the encoded data-table region name ({@code PARTITION_ID_LENGTH} + * bytes), which is region-local and so differs by design between the active and the standby. The + * standby regenerates the rowkey with its own partition_id, so a byte-equal compare on the full + * rowkey (as {@link #assertTablesEqualAcrossClusters} does) would always fail. Everything else is + * identical: the rowkey suffix (ROW_TIMESTAMP + data PK) and every cell (the index is uncovered, + * so both sides write the empty cell {@code UNVERIFIED} and there is no post phase to flip it). + * This compares the rowkey suffix after the partition_id and all cell content except the row. + */ + protected void assertCDCIndexEqualAcrossClusters(String hbaseTableName) throws Exception { + final int pidLen = PartitionIdFunction.PARTITION_ID_LENGTH; + TableName tn = TableName.valueOf(hbaseTableName); + try ( + org.apache.hadoop.hbase.client.Connection hconn1 = ConnectionFactory.createConnection(conf1); + org.apache.hadoop.hbase.client.Connection hconn2 = ConnectionFactory.createConnection(conf2); + Table table1 = hconn1.getTable(tn); Table table2 = hconn2.getTable(tn)) { + + Scan scan = new Scan(); + scan.readAllVersions(); + try (ResultScanner scanner1 = table1.getScanner(scan); + ResultScanner scanner2 = table2.getScanner(scan)) { + int rowCount = 0; + while (true) { + Result r1 = scanner1.next(); + Result r2 = scanner2.next(); + if (r1 == null && r2 == null) { + break; + } + assertNotNull(String.format("CDC index %s: cluster 2 has fewer rows at row %d", + hbaseTableName, rowCount), r2); + assertNotNull(String.format("CDC index %s: cluster 1 has fewer rows at row %d", + hbaseTableName, rowCount), r1); + + byte[] rk1 = r1.getRow(); + byte[] rk2 = r2.getRow(); + assertTrue(String.format("CDC index %s row %d: rowkey shorter than partition_id", + hbaseTableName, rowCount), rk1.length >= pidLen && rk2.length >= pidLen); + assertArrayEquals( + String.format("CDC index %s row %d: rowkey suffix after partition_id differs", + hbaseTableName, rowCount), + Arrays.copyOfRange(rk1, pidLen, rk1.length), + Arrays.copyOfRange(rk2, pidLen, rk2.length)); + + List cells1 = r1.listCells(); + List cells2 = r2.listCells(); + assertEquals( + String.format("CDC index %s row %d: cell count differs", hbaseTableName, rowCount), + cells1.size(), cells2.size()); + for (int i = 0; i < cells1.size(); i++) { + Cell c1 = cells1.get(i); + Cell c2 = cells2.get(i); + String where = + String.format("CDC index %s row %d cell %d", hbaseTableName, rowCount, i); + assertTrue(where + ": family differs", CellUtil.matchingFamily(c1, c2)); + assertTrue(where + ": qualifier differs", CellUtil.matchingQualifier(c1, c2)); + assertEquals(where + ": timestamp differs", c1.getTimestamp(), c2.getTimestamp()); + assertEquals(where + ": type differs", c1.getType(), c2.getType()); + assertTrue(where + ": value differs", CellUtil.matchingValue(c1, c2)); + } + rowCount++; + } + LOG.info("CDC index {} matches across clusters (partition_id-stripped): {} rows verified", + hbaseTableName, rowCount); + } + } + } + + /** + * Asserts the CDC index's {@code _IDX_PRE_} serialized-payload column is present iff + * {@code serializeCDCMutations} is enabled on the cluster. Scans the standby (cluster 2) copy, + * which is the regenerated one, so a present payload also proves the standby reproduced it. + */ + protected void assertCDCIndexPayloadMatchesConfig(String hbaseTableName) throws Exception { + boolean serialize = conf1.getBoolean(PHOENIX_INDEX_CDC_MUTATION_SERIALIZE, false); + TableName tn = TableName.valueOf(hbaseTableName); + int payloadCells = 0; + try ( + org.apache.hadoop.hbase.client.Connection hconn = ConnectionFactory.createConnection(conf2); + Table table = hconn.getTable(tn)) { + Scan scan = new Scan(); + scan.readAllVersions(); + try (ResultScanner scanner = table.getScanner(scan)) { + for (Result r = scanner.next(); r != null; r = scanner.next()) { + for (Cell c : r.listCells()) { + if (CellUtil.matchingQualifier(c, QueryConstants.CDC_INDEX_PRE_MUTATIONS_CQ_BYTES)) { + payloadCells++; + } + } + } + } + } + if (serialize) { + assertTrue("serializeCDCMutations=true but CDC index " + hbaseTableName + + " carries no _IDX_PRE_ payload cell on the standby", payloadCells > 0); + } else { + assertEquals("serializeCDCMutations=false but CDC index " + hbaseTableName + + " carries _IDX_PRE_ payload cells on the standby", 0, payloadCells); + } + } +} diff --git a/phoenix-core/src/it/java/org/apache/phoenix/replication/ReplicationLogGroupEventualIndexIT.java b/phoenix-core/src/it/java/org/apache/phoenix/replication/ReplicationLogGroupEventualIndexIT.java new file mode 100644 index 00000000000..195f9d1e2e0 --- /dev/null +++ b/phoenix-core/src/it/java/org/apache/phoenix/replication/ReplicationLogGroupEventualIndexIT.java @@ -0,0 +1,144 @@ +/* + * 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.replication; + +import static org.apache.phoenix.hbase.index.IndexRegionObserver.PHOENIX_INDEX_CDC_CONSUMER_ENABLED; +import static org.apache.phoenix.query.BaseTest.generateUniqueName; + +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.util.Arrays; +import org.apache.phoenix.end2end.NeedsOwnMiniClusterTest; +import org.apache.phoenix.jdbc.FailoverPhoenixConnection; +import org.apache.phoenix.util.CDCUtil; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.experimental.categories.Category; + +/** + * CDC index behind a CONSISTENCY=EVENTUAL secondary index. Lives in its own IT (not in + * {@link ReplicationLogGroupIT}) because the {@code serializeCDCMutations} variant is a + * cluster-level config and is exercised by the + * {@link ReplicationLogGroupEventualIndexWithSerializeCDCIT} subclass, which inherits exactly this + * one test. The base class runs it under the default {@code serializeCDCMutations=false}. + */ +@Category(NeedsOwnMiniClusterTest.class) +public class ReplicationLogGroupEventualIndexIT extends ReplicationLogGroupBaseIT { + + @BeforeClass + public static void doSetup() throws Exception { + setupEventualIndexClusters(); + } + + /** + * Disables the IndexCDCConsumer on both clusters, then starts them. CDC-index regeneration is + * verified directly and the consumer's downstream secondary-index convergence is out of scope, so + * it stays off. The {@code serializeCDCMutations} subclass calls this after setting its own + * toggle so the consumer-disable lives in one place. + */ + protected static void setupEventualIndexClusters() throws Exception { + conf1.setBoolean(PHOENIX_INDEX_CDC_CONSUMER_ENABLED, false); + conf2.setBoolean(PHOENIX_INDEX_CDC_CONSUMER_ENABLED, false); + setupClusters(); + } + + /** + * An eventually-consistent secondary index auto-creates a CDC index behind it ({@code CDC_ + * + + * } -> {@code PHOENIX_CDC_INDEX_CDC_ + * +
+ * }, MetaDataClient.java:2473). That CDC index is STRONG/uncovered and written inline on the data + * path, so the standby regenerates it from the data record + per-(row,ts) pre-image with its own + * partition_id, exactly like a plain CDC index. This verifies the CDC index table matches across + * clusters (modulo partition_id). The eventual secondary index table itself is written only by + * the (here-disabled) IndexCDCConsumer, so it stays empty on both clusters and is not compared. + * The serialized downstream-index payload column is present iff {@code serializeCDCMutations} is + * enabled (asserted via {@link #assertCDCIndexPayloadMatchesConfig}); the {@code serialize=true} + * variant is exercised by {@link ReplicationLogGroupEventualIndexWithSerializeCDCIT}. + */ + @Test + public void testEventualIndexCDCTable() throws Exception { + final String tableName = "T_" + generateUniqueName(); + final String indexName = "I_" + generateUniqueName(); + final String cdcIndexName = CDCUtil.getCDCIndexName("CDC_" + tableName); + String createTableDdl = String.format( + "create table if not exists %s (pk integer not null " + "primary key, a varchar, b varchar)", + tableName); + String createIndexDdl = + String.format("create index if not exists %s on %s (a) include (b) consistency=eventual", + indexName, tableName); + // CONSISTENCY=EVENTUAL auto-creates this CDC object (CDC_) and its physical CDC + // index. We must issue the CREATE CDC explicitly, before the index, on BOTH clusters. The two + // mini-clusters share one JVM metadata cache: cluster 1's CREATE INDEX registers the index + // PTable in that cache, so when the same DDL replays on cluster 2 the index appears to already + // exist and createIndex returns early (table == null) WITHOUT reaching the nested + // createCDCForEventuallyConsistentIndex (MetaDataClient.java:1915-1925). The CDC index physical + // region would then never be created on cluster 2 and the replayed index writes fail with + // "Cannot get replica 0 location". Issuing CREATE CDC IF NOT EXISTS directly runs CREATE + // UNCOVERED INDEX -> ensureTableCreated (which checks the per-cluster HBase admin, not the + // shared cache), so the physical CDC index region is created on each cluster. + String createCdcDdl = + String.format("create cdc if not exists \"CDC_%s\" on %s", tableName, tableName); + + try (FailoverPhoenixConnection conn = (FailoverPhoenixConnection) DriverManager + .getConnection(CLUSTERS.getJdbcHAUrl(), clientProps)) { + conn.createStatement().execute(createTableDdl); + conn.createStatement().execute(createCdcDdl); + conn.createStatement().execute(createIndexDdl); + conn.commit(); + + PreparedStatement upsert = + conn.prepareStatement("upsert into " + tableName + " VALUES(?, ?, ?)"); + for (int i = 0; i < 10; ++i) { + upsert.setInt(1, i); + upsert.setString(2, "a_" + i); + upsert.setString(3, "b_" + i); + upsert.executeUpdate(); + conn.commit(); + } + + PreparedStatement update = + conn.prepareStatement("upsert into " + tableName + " (pk, a) VALUES(?, ?)"); + for (int i = 0; i < 5; ++i) { + update.setInt(1, i); + update.setString(2, "a2_" + i); + update.executeUpdate(); + } + conn.commit(); + + PreparedStatement delete = + conn.prepareStatement("delete from " + tableName + " where pk = ?"); + for (int i = 5; i < 8; ++i) { + delete.setInt(1, i); + delete.executeUpdate(); + } + conn.commit(); + + replayAndVerifyAcrossClusters(Arrays.asList(createTableDdl, createCdcDdl, createIndexDdl), + tableName); + assertCDCIndexEqualAcrossClusters(cdcIndexName); + // The cross-cluster compare above is symmetric, so it passes whether or not the serialized + // downstream-index payload is present. Assert it positively against the live config so a + // serialize=true run that failed to propagate would fail loudly rather than pass + // degenerately. + assertCDCIndexPayloadMatchesConfig(cdcIndexName); + } + } +} diff --git a/phoenix-core/src/it/java/org/apache/phoenix/replication/ReplicationLogGroupEventualIndexWithSerializeCDCIT.java b/phoenix-core/src/it/java/org/apache/phoenix/replication/ReplicationLogGroupEventualIndexWithSerializeCDCIT.java new file mode 100644 index 00000000000..36cf9c60855 --- /dev/null +++ b/phoenix-core/src/it/java/org/apache/phoenix/replication/ReplicationLogGroupEventualIndexWithSerializeCDCIT.java @@ -0,0 +1,46 @@ +/* + * 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.replication; + +import static org.apache.phoenix.hbase.index.IndexRegionObserver.PHOENIX_INDEX_CDC_MUTATION_SERIALIZE; + +import org.apache.phoenix.end2end.NeedsOwnMiniClusterTest; +import org.junit.BeforeClass; +import org.junit.experimental.categories.Category; + +/** + * Runs the eventual-index CDC scenario with {@code serializeCDCMutations=true}: the standby + * regenerates the CDC index row including the serialized downstream-index {@code _IDX_PRE_}/ + * {@code _IDX_POST_} payload (see {@code prepareEventuallyConsistentIndexMutations}). Inherits the + * single test from {@link ReplicationLogGroupEventualIndexIT} and only flips the cluster-level + * {@code serializeCDCMutations} config; {@code assertCDCIndexPayloadMatchesConfig} then asserts the + * payload is present. + */ +@Category(NeedsOwnMiniClusterTest.class) +public class ReplicationLogGroupEventualIndexWithSerializeCDCIT + extends ReplicationLogGroupEventualIndexIT { + + @BeforeClass + public static void doSetup() throws Exception { + // serializeCDCMutations is read once at IRO start(), so set it before the clusters come up; + // setupEventualIndexClusters() handles the consumer-disable and cluster start. + conf1.setBoolean(PHOENIX_INDEX_CDC_MUTATION_SERIALIZE, true); + conf2.setBoolean(PHOENIX_INDEX_CDC_MUTATION_SERIALIZE, true); + setupEventualIndexClusters(); + } +} 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 c4dd5149b20..fc4cc9f5633 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 @@ -17,249 +17,68 @@ */ package org.apache.phoenix.replication; -import static org.apache.phoenix.jdbc.HighAvailabilityGroup.PHOENIX_HA_GROUP_ATTR; -import static org.apache.phoenix.jdbc.HighAvailabilityTestingUtility.getHighAvailibilityGroup; +import static org.apache.phoenix.hbase.index.IndexRegionObserver.PHOENIX_INDEX_CDC_CONSUMER_ENABLED; import static org.apache.phoenix.jdbc.PhoenixDatabaseMetaData.SYSTEM_CATALOG_NAME; import static org.apache.phoenix.jdbc.PhoenixDatabaseMetaData.SYSTEM_CHILD_LINK_NAME; import static org.apache.phoenix.query.BaseTest.generateUniqueName; -import static org.apache.phoenix.replication.ReplicationShardDirectoryManager.PHOENIX_REPLICATION_ROUND_DURATION_SECONDS_KEY; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; -import java.io.IOException; import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; -import java.util.ArrayList; +import java.sql.ResultSet; +import java.sql.Statement; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.Properties; +import java.util.Random; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; -import org.apache.hadoop.fs.FileStatus; import org.apache.hadoop.fs.FileSystem; import org.apache.hadoop.fs.Path; +import org.apache.hadoop.hbase.Cell; +import org.apache.hadoop.hbase.CellUtil; import org.apache.hadoop.hbase.HBaseTestingUtility; import org.apache.hadoop.hbase.MiniHBaseCluster; import org.apache.hadoop.hbase.ServerName; import org.apache.hadoop.hbase.TableName; -import org.apache.hadoop.hbase.client.ConnectionFactory; import org.apache.hadoop.hbase.client.Mutation; -import org.apache.hadoop.hbase.client.RegionLocator; -import org.apache.hadoop.hbase.client.Result; -import org.apache.hadoop.hbase.client.ResultScanner; -import org.apache.hadoop.hbase.client.Scan; -import org.apache.hadoop.hbase.client.Table; -import org.apache.hadoop.hbase.regionserver.HRegionServer; import org.apache.hadoop.hbase.util.Bytes; import org.apache.hadoop.hbase.util.JVMClusterUtil; import org.apache.hadoop.hbase.util.Threads; import org.apache.phoenix.end2end.NeedsOwnMiniClusterTest; import org.apache.phoenix.hbase.index.IndexRegionObserver; import org.apache.phoenix.jdbc.FailoverPhoenixConnection; -import org.apache.phoenix.jdbc.HABaseIT; -import org.apache.phoenix.jdbc.HighAvailabilityGroup; -import org.apache.phoenix.jdbc.HighAvailabilityPolicy; -import org.apache.phoenix.jdbc.HighAvailabilityTestingUtility; -import org.apache.phoenix.jdbc.PhoenixDriver; +import org.apache.phoenix.jdbc.PhoenixResultSet; import org.apache.phoenix.query.PhoenixTestBuilder; import org.apache.phoenix.query.QueryConstants; import org.apache.phoenix.replication.reader.ReplicationLogProcessor; -import org.apache.phoenix.replication.tool.LogFileAnalyzer; -import org.apache.phoenix.util.TestUtil; -import org.junit.After; -import org.junit.AfterClass; -import org.junit.Before; +import org.apache.phoenix.util.CDCUtil; +import org.apache.phoenix.util.QueryUtil; import org.junit.BeforeClass; import org.junit.Ignore; -import org.junit.Rule; import org.junit.Test; import org.junit.experimental.categories.Category; -import org.junit.rules.TestName; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.apache.phoenix.thirdparty.com.google.common.collect.Maps; @Category(NeedsOwnMiniClusterTest.class) -public class ReplicationLogGroupIT extends HABaseIT { - private static final Logger LOG = LoggerFactory.getLogger(ReplicationLogGroupIT.class); - - @Rule - public TestName name = new TestName(); - - private Properties clientProps = new Properties(); - private String haGroupName; - private HighAvailabilityGroup haGroup; - private ReplicationLogGroup logGroup; +public class ReplicationLogGroupIT extends ReplicationLogGroupBaseIT { @BeforeClass public static void doSetup() throws Exception { - conf1.setInt(PHOENIX_REPLICATION_ROUND_DURATION_SECONDS_KEY, 20); - CLUSTERS.start(); - DriverManager.registerDriver(PhoenixDriver.INSTANCE); - } - - @AfterClass - public static void tearDownAfterClass() throws Exception { - DriverManager.deregisterDriver(PhoenixDriver.INSTANCE); - CLUSTERS.close(); - } - - @Before - public void beforeTest() throws Exception { - LOG.info("Starting test {}", name.getMethodName()); - haGroupName = name.getMethodName(); - clientProps = HighAvailabilityTestingUtility.getHATestProperties(); - clientProps.setProperty(PHOENIX_HA_GROUP_ATTR, haGroupName); - CLUSTERS.initClusterRole(haGroupName, HighAvailabilityPolicy.FAILOVER); - haGroup = getHighAvailibilityGroup(CLUSTERS.getJdbcHAUrl(), clientProps); - LOG.info("Initialized haGroup {} with URL {}", haGroup, CLUSTERS.getJdbcHAUrl()); - logGroup = getReplicationLogGroup(); - } - - @After - public void afterTest() throws Exception { - LOG.info("Starting cleanup for test {}", name.getMethodName()); - logGroup.close(); - LOG.info("Ending cleanup for test {}", name.getMethodName()); - } - - private ReplicationLogGroup getReplicationLogGroup() throws IOException { - HRegionServer rs = CLUSTERS.getHBaseCluster1().getHBaseCluster().getRegionServer(0); - return ReplicationLogGroup.get(conf1, rs.getServerName(), haGroupName); - } - - private Map> groupLogsByTable() throws Exception { - LogFileAnalyzer analyzer = new LogFileAnalyzer(); - // use peer cluster conf - analyzer.setConf(conf2); - Path standByLogDir = logGroup.getOrCreatePeerShardManager().getRootDirectoryPath(); - LOG.info("Analyzing log files at {}", standByLogDir); - String[] args = { "--check", standByLogDir.toString() }; - assertEquals(0, analyzer.run(args)); - return analyzer.groupLogsByTable(standByLogDir.toString()); - } - - private int getCountForTable(Map> logsByTable, String tableName) - throws Exception { - List mutations = logsByTable.get(tableName); - return mutations != null ? mutations.size() : 0; - } - - private Map countRecordsByTable() throws Exception { - LogFileAnalyzer analyzer = new LogFileAnalyzer(); - // use peer cluster conf - analyzer.setConf(conf2); - Path standByLogDir = logGroup.getOrCreatePeerShardManager().getRootDirectoryPath(); - return analyzer.countRecordsByTable(standByLogDir.toString()); - } - - private void verifyReplication(Map expected) throws Exception { - // first close the logGroup - logGroup.close(); - Map> mutationsByTable = groupLogsByTable(); - dumpTableLogCount(mutationsByTable); - for (Map.Entry entry : expected.entrySet()) { - String tableName = entry.getKey(); - int expectedMutationCount = entry.getValue(); - List mutations = mutationsByTable.get(tableName); - int actualMutationCount = mutations != null ? mutations.size() : 0; - try { - if (!tableName.equals(SYSTEM_CATALOG_NAME)) { - assertEquals(String.format("For table %s", tableName), expectedMutationCount, - actualMutationCount); - } else { - // special handling for syscat - assertTrue("For SYSCAT", actualMutationCount >= expectedMutationCount); - } - } catch (AssertionError e) { - // create a regular connection - try (Connection conn = DriverManager.getConnection(CLUSTERS.getJdbcUrl1(haGroup))) { - TestUtil.dumpTable(conn, TableName.valueOf(tableName)); - throw e; - } - } - } - } - - private void dumpTableLogCount(Map> mutationsByTable) { - LOG.info("Dump table log count for test {}", name.getMethodName()); - for (Map.Entry> table : mutationsByTable.entrySet()) { - LOG.info("#Log entries for {} = {}", table.getKey(), table.getValue().size()); - } - } - - private void moveRegionToServer(TableName tableName, ServerName sn) throws Exception { - HBaseTestingUtility util = CLUSTERS.getHBaseCluster1(); - try (RegionLocator locator = util.getConnection().getRegionLocator(tableName)) { - String regEN = locator.getAllRegionLocations().get(0).getRegionInfo().getEncodedName(); - while (!sn.equals(locator.getAllRegionLocations().get(0).getServerName())) { - LOG.info("Moving region {} of table {} to server {}", regEN, tableName, sn); - util.getAdmin().move(Bytes.toBytes(regEN), sn); - Thread.sleep(100); - } - LOG.info("Moved region {} of table {} to server {}", regEN, tableName, sn); - } - } - - private PhoenixTestBuilder.SchemaBuilder createViewHierarchy() throws Exception { - // Define the test schema. - // 1. Table with columns => (ORG_ID, KP, COL1, COL2, COL3), PK => (ORG_ID, KP) - // 2. GlobalView with columns => (ID, COL4, COL5, COL6), PK => (ID) - // 3. Tenant with columns => (ZID, COL7, COL8, COL9), PK => (ZID) - final PhoenixTestBuilder.SchemaBuilder schemaBuilder = - new PhoenixTestBuilder.SchemaBuilder(CLUSTERS.getJdbcHAUrl()); - PhoenixTestBuilder.SchemaBuilder.ConnectOptions connectOptions = - new PhoenixTestBuilder.SchemaBuilder.ConnectOptions(); - connectOptions.setConnectProps(clientProps); - PhoenixTestBuilder.SchemaBuilder.TableOptions tableOptions = - PhoenixTestBuilder.SchemaBuilder.TableOptions.withDefaults(); - PhoenixTestBuilder.SchemaBuilder.GlobalViewOptions globalViewOptions = - PhoenixTestBuilder.SchemaBuilder.GlobalViewOptions.withDefaults(); - PhoenixTestBuilder.SchemaBuilder.TenantViewOptions tenantViewWithOverrideOptions = - PhoenixTestBuilder.SchemaBuilder.TenantViewOptions.withDefaults(); - PhoenixTestBuilder.SchemaBuilder.TenantViewIndexOptions tenantViewIndexOverrideOptions = - PhoenixTestBuilder.SchemaBuilder.TenantViewIndexOptions.withDefaults(); - schemaBuilder.withConnectOptions(connectOptions).withTableOptions(tableOptions) - .withGlobalViewOptions(globalViewOptions).withTenantViewOptions(tenantViewWithOverrideOptions) - .withTenantViewIndexOptions(tenantViewIndexOverrideOptions).buildWithNewTenant(); - return schemaBuilder; - } - - private void replayAndVerifyAcrossClusters(List ddlStatements, String... tablesToVerify) - throws Exception { - Path standByLogDir = logGroup.getOrCreatePeerShardManager().getRootDirectoryPath(); - - // Create the same schema on cluster 2 - try (Connection conn2 = CLUSTERS.getCluster2Connection(haGroup)) { - for (String ddl : ddlStatements) { - conn2.createStatement().execute(ddl); - } - conn2.commit(); - } - - // Replay replication log on cluster 2 - FileSystem fs = standByLogDir.getFileSystem(conf2); - List logFiles = findLogFiles(standByLogDir, fs); - assertTrue("Should have at least one log file", !logFiles.isEmpty()); - ReplicationLogProcessor processor = ReplicationLogProcessor.get(conf2, haGroupName); - try { - for (Path logFile : logFiles) { - LOG.info("Replaying log file: {}", logFile); - processor.processLogFile(fs, logFile); - } - } finally { - processor.close(); - } - - // Verify tables match across clusters at the HBase cell level - for (String table : tablesToVerify) { - assertTablesEqualAcrossClusters(table); - } + // Disable the IndexCDCConsumer on both clusters: CDC-index regeneration is verified directly, + // and the consumer's downstream secondary-index convergence is out of scope for these tests. + conf1.setBoolean(PHOENIX_INDEX_CDC_CONSUMER_ENABLED, false); + conf2.setBoolean(PHOENIX_INDEX_CDC_CONSUMER_ENABLED, false); + setupClusters(); } @Test @@ -297,7 +116,7 @@ public void testUpsertSelectReplicatesViaCloneConnection() throws Exception { Map expected = Maps.newHashMap(); expected.put(sourceTable, rowCount); // direct upserts on the parent connection - expected.put(targetTable, rowCount); // upsert-select rows — currently fails: gets 0 + expected.put(targetTable, rowCount); // upsert-select rows expected.put(SYSTEM_CATALOG_NAME, 0); expected.put(SYSTEM_CHILD_LINK_NAME, 0); verifyReplication(expected); @@ -309,6 +128,7 @@ public void testAppendAndSync() throws Exception { final String indexName1 = "I_" + generateUniqueName(); final String indexName2 = "I_" + generateUniqueName(); final String indexName3 = "L_" + generateUniqueName(); + final String indexName4 = "U_" + generateUniqueName(); String createTableDdl = String.format("create table if not exists %s (id1 integer not null, " + "id2 integer not null, val1 varchar, val2 varchar " + "constraint pk primary key (id1, id2))", tableName); @@ -318,6 +138,11 @@ public void testAppendAndSync() throws Exception { .format("create index if not exists %s on %s (val2) include (val1)", indexName2, tableName); String createLocalIndexDdl = String.format( "create local index if not exists %s on %s (id2,val1) include (val2)", indexName3, tableName); + // Uncovered index (no INCLUDE): rows are written UNVERIFIED in PRE and never marked VERIFIED in + // POST (IRO:2248), so the reader joins back to the data table. Both clusters write only + // UNVERIFIED rows, so cross-cluster cell equality still holds. + String createUncoveredIndexDdl = + String.format("create uncovered index if not exists %s on %s (val1)", indexName4, tableName); try (FailoverPhoenixConnection conn = (FailoverPhoenixConnection) DriverManager .getConnection(CLUSTERS.getJdbcHAUrl(), clientProps)) { @@ -325,6 +150,7 @@ public void testAppendAndSync() throws Exception { conn.createStatement().execute(createIndex1Ddl); conn.createStatement().execute(createIndex2Ddl); conn.createStatement().execute(createLocalIndexDdl); + conn.createStatement().execute(createUncoveredIndexDdl); conn.commit(); PreparedStatement stmt = conn.prepareStatement("upsert into " + tableName + " VALUES(?, ?, ?, ?)"); @@ -391,53 +217,214 @@ public void testAppendAndSync() throws Exception { } } - // Verify the system tables are never replicated, and flush the log group (verifyReplication - // closes it) before replay. We deliberately do NOT assert exact data/index mutation totals + // Assert the headline invariant of this feature: the index physical tables receive ZERO + // replication records (the standby regenerates index entries from the data record). System + // tables likewise never replicate. We deliberately do NOT assert exact data mutation totals // here: the multi-pass update workload's per-table counts are dominated by index-maintenance // internals (local-index key churn, verified/unverified empty-column writes) rather than by // the coalescing under test, and coalescing is mutation-count invariant by construction. The // authoritative correctness check for this workload is the cross-cluster cell-level equality // below; the record-count contract of coalescing is pinned separately in - // testAppendAndSyncSingleBatchRecordCount. + // testSingleBatchRecordCount. Map expected = Maps.newHashMap(); expected.put(SYSTEM_CATALOG_NAME, 0); expected.put(SYSTEM_CHILD_LINK_NAME, 0); + expected.put(indexName1, 0); + expected.put(indexName2, 0); + expected.put(indexName4, 0); verifyReplication(expected); // Replay on cluster 2 and verify cross-cluster cell-level equality - replayAndVerifyAcrossClusters( - Arrays.asList(createTableDdl, createIndex1Ddl, createIndex2Ddl, createLocalIndexDdl), - tableName, indexName1, indexName2); + replayAndVerifyAcrossClusters(Arrays.asList(createTableDdl, createIndex1Ddl, createIndex2Ddl, + createLocalIndexDdl, createUncoveredIndexDdl), tableName, indexName1, indexName2, + indexName4); } } /** - * Pins the per-batch coalescing contract: one server-side batch on a table with one index emits - * exactly three log records -- one for the data table, one for the index PRE phase, and one for - * the index POST phase -- regardless of how many rows the batch contains. Before coalescing this - * batch would have produced one record per mutation (3 rows x ~3 mutations each); coalescing - * collapses each (table, phase) into a single cell-stream record. Cross-cluster cell equality - * confirms the collapsed records still reconstruct the correct mutations on the standby. + * Uncovered-index-only variant exercising the path where the active NEVER calls + * {@code getCurrentRowStates}. The skip at {@code IndexRegionObserver:2513-2519} fires when the + * only index is uncovered and every mutation already carries the indexed column + * ({@code isPartialUncoveredIndexMutation == false}). With nothing read into + * {@code dataRowStates}, the active ships NO pre-image cell; the standby receives a + * self-contained full mutation, takes the same skip, and regenerates the uncovered index purely + * from the data cells. {@link #testAppendAndSync}'s uncovered index cannot reach this path: that + * table has a global index (forcing the read) and does val2-only updates (partial w.r.t. the val1 + * index). */ @Test - public void testAppendAndSyncSingleBatchRecordCount() throws Exception { + public void testUncoveredIndexNoCurrentRowState() throws Exception { + final String tableName = "T_" + generateUniqueName(); + final String indexName = "U_" + generateUniqueName(); + String createTableDdl = String.format( + "create table if not exists %s (id integer not null primary key, val1 varchar, val2 varchar)", + tableName); + String createIndexDdl = + String.format("create uncovered index if not exists %s on %s (val1)", indexName, tableName); + + try (FailoverPhoenixConnection conn = (FailoverPhoenixConnection) DriverManager + .getConnection(CLUSTERS.getJdbcHAUrl(), clientProps)) { + conn.createStatement().execute(createTableDdl); + conn.createStatement().execute(createIndexDdl); + conn.commit(); + + // Full upserts (all columns present), so the indexed column val1 is always supplied and the + // batch stays non-partial, keeping the active on the skip path. + PreparedStatement stmt = + conn.prepareStatement("upsert into " + tableName + " VALUES(?, ?, ?)"); + int rowCount = 10; + for (int i = 0; i < rowCount; ++i) { + stmt.setInt(1, i); + stmt.setString(2, "val1_" + i); + stmt.setString(3, "val2_" + i); + stmt.executeUpdate(); + } + conn.commit(); + + // Re-upsert the same rows as full mutations (val1 unchanged, val2 changed) so the index key + // is stable and the batch remains non-partial. + for (int i = 0; i < rowCount; ++i) { + stmt.setInt(1, i); + stmt.setString(2, "val1_" + i); + stmt.setString(3, "val2_updated_" + i); + stmt.executeUpdate(); + } + conn.commit(); + + // The uncovered index physical table must receive zero replication records. + Map expected = Maps.newHashMap(); + expected.put(SYSTEM_CATALOG_NAME, 0); + expected.put(SYSTEM_CHILD_LINK_NAME, 0); + expected.put(indexName, 0); + verifyReplication(expected); + + replayAndVerifyAcrossClusters(Arrays.asList(createTableDdl, createIndexDdl), tableName, + indexName); + } + } + + /** + * Local-index-only variant of {@link #testAppendAndSync}: the table has a local index but no + * global/uncovered/transform index. Such a table never enters the global-index branch that + * captures and ships the per-row PRE_IMAGE, so the active must capture it from the local-index + * prior-row scan instead (see {@code IndexRegionObserver.captureLocalIndexPreImageCells}); + * without that the standby's {@code PreImageLocalTable} would have no prior state and would miss + * covered columns and old-key tombstones. {@code testAppendAndSync} has both index types and so + * would not exercise this path. Local-index cells live in the data table's own {@code L#0} + * family, so verifying the data table cross-cluster also verifies the regenerated local index. + */ + @Test + public void testLocalIndexOnly() throws Exception { + final String tableName = "T_" + generateUniqueName(); + final String localIndexName = "L_" + generateUniqueName(); + String createTableDdl = String.format("create table if not exists %s (id1 integer not null, " + + "id2 integer not null, val1 varchar, val2 varchar " + + "constraint pk primary key (id1, id2))", tableName); + String createLocalIndexDdl = + String.format("create local index if not exists %s on %s (id2,val1) include (val2)", + localIndexName, tableName); + + try (FailoverPhoenixConnection conn = (FailoverPhoenixConnection) DriverManager + .getConnection(CLUSTERS.getJdbcHAUrl(), clientProps)) { + conn.createStatement().execute(createTableDdl); + conn.createStatement().execute(createLocalIndexDdl); + conn.commit(); + + // Insert 50 rows (val2 null) across 5 commits. + PreparedStatement stmt = + conn.prepareStatement("upsert into " + tableName + " VALUES(?, ?, ?, ?)"); + for (int i = 0; i < 5; ++i) { + for (int j = 0; j < 10; ++j) { + stmt.setInt(1, i); + stmt.setInt(2, j); + stmt.setString(3, "abcdefghijklmnopqrstuvwxyz"); + stmt.setString(4, null); + stmt.executeUpdate(); + } + conn.commit(); + } + + // Update the covered column val2 only (local index row key unchanged): exercises carrying a + // covered cell forward from the pre-image. + PreparedStatement updateVal2 = + conn.prepareStatement("upsert into " + tableName + " (id1, id2, val2) VALUES(?, ?, ?)"); + for (int i = 0; i < 5; ++i) { + for (int j = 0; j < 10; ++j) { + updateVal2.setInt(1, i); + updateVal2.setInt(2, j); + updateVal2.setString(3, "val2_" + i + "_" + j); + updateVal2.executeUpdate(); + } + } + conn.commit(); + + // Update the indexed column val1 (local index row key CHANGES): exercises the old-key + // DeleteFamily tombstone, which requires the prior row state from the pre-image. + PreparedStatement updateVal1 = + conn.prepareStatement("upsert into " + tableName + " (id1, id2, val1) VALUES(?, ?, ?)"); + for (int i = 0; i < 5; ++i) { + for (int j = 0; j < 10; ++j) { + updateVal1.setInt(1, i); + updateVal1.setInt(2, j); + updateVal1.setString(3, "newval1_" + i + "_" + j); + updateVal1.executeUpdate(); + } + } + conn.commit(); + + // Delete some rows: exercises full-row DeleteFamily on both data and local index. + PreparedStatement deleteStmt = + conn.prepareStatement("delete from " + tableName + " where id1 = ? and id2 = ?"); + for (int j = 0; j < 10; ++j) { + deleteStmt.setInt(1, 0); + deleteStmt.setInt(2, j); + deleteStmt.executeUpdate(); + } + conn.commit(); + + // Replay on cluster 2 and verify cross-cluster cell-level equality. The data table scan + // covers the L#0 local-index family, so this verifies the regenerated local index too. A + // local index shares the data region, so there is no separate index physical table to assert + // zero replication records on -- cross-cluster equality is the whole check. + replayAndVerifyAcrossClusters(Arrays.asList(createTableDdl, createLocalIndexDdl), tableName); + } + } + + /** + * Pins the per-batch coalescing contract: one server-side batch on a table with one global index + * and one local index emits exactly one log record -- the coalesced data-table cell stream + * carrying the per-row pre-image -- regardless of how many rows the batch contains. Neither index + * is replicated; the standby regenerates both from the data record plus its pre-image, so the + * global index table has no replication records of its own and the captured data-table cell + * stream carries no local-index ({@code L#}) cells. This is the explicit must-have check for the + * global + local scenario: local-index updates run after pre-image capture, so they are never in + * the shipped stream and the standby cannot double-write them. Cross-cluster cell equality then + * confirms the single collapsed record still reconstructs the correct data, global index, and (in + * the data table's own {@code L#0} family) local index on the standby. + */ + @Test + public void testSingleBatchRecordCount() throws Exception { final String tableName = "T_" + generateUniqueName(); final String indexName = "I_" + generateUniqueName(); + final String localIndexName = "L_" + generateUniqueName(); String createTableDdl = String.format( "create table if not exists %s (id integer not null primary key, val1 varchar, val2 varchar)", tableName); String createIndexDdl = String .format("create index if not exists %s on %s (val1) include (val2)", indexName, tableName); + String createLocalIndexDdl = String.format( + "create local index if not exists %s on %s (val2) include (val1)", localIndexName, tableName); try (FailoverPhoenixConnection conn = (FailoverPhoenixConnection) DriverManager .getConnection(CLUSTERS.getJdbcHAUrl(), clientProps)) { conn.createStatement().execute(createTableDdl); conn.createStatement().execute(createIndexDdl); + conn.createStatement().execute(createLocalIndexDdl); conn.commit(); // Insert several rows and commit them as a SINGLE batch (autocommit off, one commit()). All - // rows in this batch coalesce into one data-table record plus one PRE and one POST record on - // the index table. + // rows in this batch coalesce into one data-table record; index entries are regenerated on + // the standby, so the index table contributes no replication records. PreparedStatement stmt = conn.prepareStatement("upsert into " + tableName + " VALUES(?, ?, ?)"); int rowCount = 5; @@ -455,12 +442,26 @@ public void testAppendAndSyncSingleBatchRecordCount() throws Exception { LOG.info("Records by table: {}", recordsByTable); assertEquals("Data table should have exactly one coalesced record for the batch", Integer.valueOf(1), recordsByTable.get(tableName)); - assertEquals("Index table should have exactly two records (PRE + POST) for the batch", - Integer.valueOf(2), recordsByTable.get(indexName)); + assertNull("Global index table should have no replication records; entries are regenerated" + + " standby", recordsByTable.get(indexName)); - // Replay on cluster 2 and verify cross-cluster cell-level equality. - replayAndVerifyAcrossClusters(Arrays.asList(createTableDdl, createIndexDdl), tableName, - indexName); + // The captured data-table cell stream must carry no local-index (L#) cells: local-index + // updates run after pre-image capture and so are never shipped. The standby regenerates them. + Map> logsByTable = groupLogsByTable(); + for (Mutation m : logsByTable.get(tableName)) { + for (Cell cell : m.getFamilyCellMap().values().stream().flatMap(List::stream) + .collect(Collectors.toList())) { + String family = Bytes.toString(CellUtil.cloneFamily(cell)); + assertFalse( + "Captured data record must not contain local-index (L#) cells, found family " + family, + family.startsWith(QueryConstants.LOCAL_INDEX_COLUMN_FAMILY_PREFIX)); + } + } + + // Replay on cluster 2 and verify cross-cluster cell-level equality. Verifying the data table + // also verifies the regenerated local index, whose cells live in the data table's L#0 family. + replayAndVerifyAcrossClusters( + Arrays.asList(createTableDdl, createIndexDdl, createLocalIndexDdl), tableName, indexName); } } @@ -524,7 +525,7 @@ public void testAppendAndSyncNoIndex() throws Exception { * that flow through the coprocessor merge path the codec must round-trip correctly. */ @Test - public void testAppendAndSyncOnDuplicateKeyUpdate() throws Exception { + public void testOnDuplicateKeyUpdate() throws Exception { final String tableName = "T_" + generateUniqueName(); String createTableDdl = String.format("create table if not exists %s " + "(pk varchar primary key, counter1 bigint, counter2 varchar)", tableName); @@ -573,13 +574,102 @@ public void testAppendAndSyncOnDuplicateKeyUpdate() throws Exception { } } + /** + * Atomic + global index: ON DUPLICATE KEY UPDATE on a table that also has a global index covering + * the mutated column. The active resolves the on-dup before pre-image capture, so the + * post-resolution data cells (including any DeleteColumn cells the on-dup generates) are captured + * into the row's {@code (row, ts)} group with their pre-image; the global index is not + * replicated. On the standby the reconstructed mutations carry no {@code ATOMIC_OP_ATTRIB} (it is + * not a replication attribute), so {@code identifyMutationTypes} leaves {@code hasAtomic} false: + * the standby does not re-resolve the on-dup and the {@code Preconditions.checkState} guard + * against active-side resolution flags does not fire. Indexing the on-dup-mutated column makes + * its index key churn (Delete old key + Put new key) across rounds. Cross-cluster cell equality + * on the data and index tables confirms the standby regenerates the index consistently with the + * active. + *

+ * Also covers returnResult + global index: a single-row atomic upsert with {@code RETURNING *} + * sets {@code RETURN_RESULT} on the active mutation, driving {@code context.returnResult} true on + * the active. {@code RETURN_RESULT} is likewise not a replication attribute, so the standby + * leaves {@code returnResult} false and {@code checkState} does not fire; the resolved cells + * (including the index-key change) replicate and regenerate. + */ + @Test + public void testOnDuplicateKeyUpdateWithIndex() throws Exception { + final String tableName = "T_" + generateUniqueName(); + final String indexName = "I_" + generateUniqueName(); + String createTableDdl = String.format("create table if not exists %s " + + "(pk varchar primary key, counter1 bigint, counter2 varchar)", tableName); + String createIndexDdl = String.format( + "create index if not exists %s on %s (counter2) include (counter1)", indexName, tableName); + + try (FailoverPhoenixConnection conn = (FailoverPhoenixConnection) DriverManager + .getConnection(CLUSTERS.getJdbcHAUrl(), clientProps)) { + conn.createStatement().execute(createTableDdl); + conn.createStatement().execute(createIndexDdl); + conn.commit(); + + // Initial inserts for 5 distinct rows + PreparedStatement insert = + conn.prepareStatement("upsert into " + tableName + " VALUES(?, 0, 'init')"); + for (int i = 0; i < 5; ++i) { + insert.setString(1, "row_" + i); + insert.executeUpdate(); + } + conn.commit(); + + // ON DUPLICATE KEY UPDATE — increment counter1 and rewrite the indexed counter2 a few times + // per row. Each round changes the index key, so the atomic path emits Delete(oldKey) + + // Put(newKey) index work that the standby must regenerate from the captured data cells. + String dml = "UPSERT INTO " + tableName + " VALUES(?, 0, ?) " + + "ON DUPLICATE KEY UPDATE counter1 = counter1 + 1, counter2 = ?"; + PreparedStatement update = conn.prepareStatement(dml); + conn.setAutoCommit(true); + for (int round = 0; round < 3; ++round) { + for (int i = 0; i < 5; ++i) { + update.setString(1, "row_" + i); + update.setString(2, "v" + round); + update.setString(3, "v" + round); + update.executeUpdate(); + } + } + + // Set the indexed column to null via ON DUPLICATE KEY UPDATE — generates DeleteColumn cells + // on the data row and deletes the index entry for the prior key. + String dmlNullify = + "UPSERT INTO " + tableName + " VALUES(?, 0, '') ON DUPLICATE KEY UPDATE counter2 = NULL"; + PreparedStatement nullify = conn.prepareStatement(dmlNullify); + for (int i = 0; i < 5; ++i) { + nullify.setString(1, "row_" + i); + nullify.executeUpdate(); + } + + // Single-row atomic upsert with RETURNING *. RETURN_RESULT is not a replication + // attribute, so the standby regenerates the index without re-resolving. + String dmlReturning = "UPSERT INTO " + tableName + " VALUES('row_0', 0, 'init') " + + "ON DUPLICATE KEY UPDATE counter1 = counter1 + 1, counter2 = 'returned' RETURNING *"; + if (isSetCorrectResultEnabledOnHBase()) { + Statement returning = conn.createStatement(); + ResultSet rs = returning.execute(dmlReturning) ? returning.getResultSet() : null; + assertNotNull("RETURNING * should produce a result set", rs); + assertTrue("RETURNING * should project the atomically updated row", rs.next()); + assertFalse("Single-row atomic upsert returns exactly one row", rs.next()); + } else { + conn.createStatement().executeUpdate(dmlReturning); + } + + // Replay on cluster 2 and verify cross-cluster cell-level equality on data and index. + replayAndVerifyAcrossClusters(Arrays.asList(createTableDdl, createIndexDdl), tableName, + indexName); + } + } + /** * Verifies cross-cluster cell-level equality after replay for a table with a Conditional TTL * expression. Conditional TTL adds coprocessor cells that get merged into the data mutation, * exercising the split-merged-mutation path. */ @Test - public void testAppendAndSyncConditionalTTL() throws Exception { + public void testConditionalTTL() throws Exception { final String tableName = "T_" + generateUniqueName(); String createTableDdl = String.format("create table if not exists %s (id1 integer not null, " + "id2 integer not null, val1 varchar, val2 varchar, expired boolean " @@ -630,6 +720,145 @@ public void testAppendAndSyncConditionalTTL() throws Exception { } } + /** + * Conditional TTL + global index: a table with a Conditional TTL expression and a global index + * covering the columns the TTL evaluation touches. The active evaluates the TTL before pre-image + * capture, so the pre-image reflects post-conditional-TTL state and the captured data cells + * (including the masking Deletes the TTL path generates) carry their {@code (row, ts)} group's + * pre-image; the global index is not replicated. On the standby the reconstructed mutations carry + * no {@code TTL} attribute (it is not a replication attribute), so {@code identifyMutationTypes} + * leaves {@code hasConditionalTTL} false: the standby does not re-evaluate the TTL and the + * {@code Preconditions.checkState} guard against active-side resolution flags does not fire. + * Cross-cluster cell equality on data and index confirms the standby regenerates the index + * consistently with the active's post-TTL state. + */ + @Test + public void testConditionalTTLWithIndex() throws Exception { + final String tableName = "T_" + generateUniqueName(); + final String indexName = "I_" + generateUniqueName(); + String createTableDdl = String.format("create table if not exists %s (id1 integer not null, " + + "id2 integer not null, val1 varchar, val2 varchar, expired boolean " + + "constraint pk primary key (id1, id2)) TTL = 'expired = TRUE'", tableName); + // Conditional TTL requires every column the TTL expression references (here: expired) to be + // present in the index, so it is covered alongside the indexed val1 / included val2. + String createIndexDdl = String.format( + "create index if not exists %s on %s (val1) include (val2, expired)", indexName, tableName); + + try (FailoverPhoenixConnection conn = (FailoverPhoenixConnection) DriverManager + .getConnection(CLUSTERS.getJdbcHAUrl(), clientProps)) { + conn.createStatement().execute(createTableDdl); + conn.createStatement().execute(createIndexDdl); + conn.commit(); + + PreparedStatement stmt = + conn.prepareStatement("upsert into " + tableName + " VALUES(?, ?, ?, ?, ?)"); + for (int i = 0; i < 5; ++i) { + for (int j = 0; j < 10; ++j) { + stmt.setInt(1, i); + stmt.setInt(2, j); + stmt.setString(3, "val1_" + i + "_" + j); + stmt.setString(4, j % 2 == 0 ? "val2_" + i + "_" + j : null); + stmt.setBoolean(5, false); + stmt.executeUpdate(); + } + conn.commit(); + } + + // Mark some rows expired + PreparedStatement expireStmt = conn + .prepareStatement("upsert into " + tableName + " (id1, id2, expired) VALUES(?, ?, true)"); + for (int j = 0; j < 5; ++j) { + expireStmt.setInt(1, 0); + expireStmt.setInt(2, j); + expireStmt.executeUpdate(); + } + conn.commit(); + + // Update the indexed column on expired rows — conditional TTL triggers extra CP cells on the + // update path and the index key churns (Delete old val1 + Put new val1). + PreparedStatement updateStmt = + conn.prepareStatement("upsert into " + tableName + " (id1, id2, val1) VALUES(?, ?, ?)"); + for (int j = 0; j < 5; ++j) { + updateStmt.setInt(1, 0); + updateStmt.setInt(2, j); + updateStmt.setString(3, "val11_" + 0 + "_" + j); + updateStmt.executeUpdate(); + } + conn.commit(); + + // Replay on cluster 2 and verify cross-cluster cell-level equality on data and index. + replayAndVerifyAcrossClusters(Arrays.asList(createTableDdl, createIndexDdl), tableName, + indexName); + } + } + + /** + * Plain CDC index (no downstream EVENTUAL secondary index). A CDC index is a STRONG-consistency + * uncovered index written inline on the data write path; its rowkey leads with + * {@code PARTITION_ID()} = the encoded data-table region name. The active ships only data cells + + * per-(row,ts) pre-image (no index records); the standby regenerates the CDC index rowkey with + * its OWN partition_id. With no EVENTUAL index, {@code IndexCDCConsumer} stays dormant on both + * clusters, so replay is deterministic. The data table is verified byte-equal; the CDC index is + * verified equal modulo its leading partition_id (which differs across clusters by design). + */ + @Test + public void testCDCIndex() throws Exception { + final String tableName = "T_" + generateUniqueName(); + final String cdcName = "CDC_" + generateUniqueName(); + final String cdcIndexName = CDCUtil.getCDCIndexName(cdcName); + String createTableDdl = String.format( + "create table if not exists %s (pk integer not null " + "primary key, a varchar, b varchar)", + tableName); + String createCdcDdl = String.format("create cdc if not exists %s on %s", cdcName, tableName); + + try (FailoverPhoenixConnection conn = (FailoverPhoenixConnection) DriverManager + .getConnection(CLUSTERS.getJdbcHAUrl(), clientProps)) { + conn.createStatement().execute(createTableDdl); + conn.createStatement().execute(createCdcDdl); + conn.commit(); + + // Inserts across several commits so the active produces multiple batches. + PreparedStatement upsert = + conn.prepareStatement("upsert into " + tableName + " VALUES(?, ?, ?)"); + for (int i = 0; i < 10; ++i) { + upsert.setInt(1, i); + upsert.setString(2, "a_" + i); + upsert.setString(3, "b_" + i); + upsert.executeUpdate(); + conn.commit(); + } + + // Update a column on some rows (new CDC change events for those rows). + PreparedStatement update = + conn.prepareStatement("upsert into " + tableName + " (pk, a) VALUES(?, ?)"); + for (int i = 0; i < 5; ++i) { + update.setInt(1, i); + update.setString(2, "a2_" + i); + update.executeUpdate(); + } + conn.commit(); + + // Delete a few rows (CDC delete events). + PreparedStatement delete = + conn.prepareStatement("delete from " + tableName + " where pk = ?"); + for (int i = 5; i < 8; ++i) { + delete.setInt(1, i); + delete.executeUpdate(); + } + conn.commit(); + + // Replay on cluster 2 and verify the data table byte-equal; verify the CDC index modulo + // its leading partition_id. + replayAndVerifyAcrossClusters(Arrays.asList(createTableDdl, createCdcDdl), tableName); + assertCDCIndexEqualAcrossClusters(cdcIndexName); + // The cross-cluster compare above is symmetric, so it passes whether or not the serialized + // downstream-index payload is present. Assert it positively against the live config so a + // serialize=true run that failed to propagate would fail loudly rather than pass + // degenerately. + assertCDCIndexPayloadMatchesConfig(cdcIndexName); + } + } + /** * This test simulates RS crashes in the middle of write transactions after the edits have been * written to the WAL but before they have been replicated to the standby cluster. Those edits @@ -693,10 +922,9 @@ public void testWALRestore() throws Exception { try (FailoverPhoenixConnection conn = (FailoverPhoenixConnection) DriverManager .getConnection(CLUSTERS.getJdbcHAUrl(), clientProps)) { Map expected = Maps.newHashMap(); - // For each row 1 Put + 1 Delete (DeleteColumn) + // For each row 1 Put + 1 Delete (DeleteColumn). + // Index mutations are not replicated; the standby regenerates them. expected.put(tableName, rowCount * 2); - // unverified + verified + delete (Delete column) - expected.put(indexName, rowCount * 3); // 1 tenant view was created // expected.put(SYSTEM_CHILD_LINK_NAME, 1); // atleast 1 log entry for syscat @@ -721,65 +949,291 @@ public void testSystemTables() throws Exception { assertTrue(getCountForTable(systemTables, SYSTEM_CATALOG_NAME) > 0); } - private List findLogFiles(Path dir, FileSystem fs) throws IOException { - List files = new ArrayList<>(); - findLogFilesRecursive(dir, fs, files); - return files; - } + /** + * Verifies that when data mutations are replayed on the standby via ReplicationLogProcessor, + * IndexRegionObserver on the standby generates index mutations from the data mutations. The + * replication log contains only data table mutations (no index mutations). + */ + @Test + public void testIndexRegenerationOnStandby() throws Exception { + final String tableName = "T_" + generateUniqueName(); + final String indexName = "I_" + generateUniqueName(); + int rowCount = 10; + + // Create table and index on cluster 1 and insert data + try (FailoverPhoenixConnection conn = (FailoverPhoenixConnection) DriverManager + .getConnection(CLUSTERS.getJdbcHAUrl(), clientProps)) { + conn.createStatement() + .execute(String.format("CREATE TABLE %s (ID1 INTEGER NOT NULL, ID2 INTEGER NOT NULL, " + + "VAL1 VARCHAR CONSTRAINT PK PRIMARY KEY (ID1, ID2))", tableName)); + conn.createStatement() + .execute(String.format("CREATE INDEX %s ON %s (VAL1)", indexName, tableName)); + conn.commit(); + PreparedStatement stmt = + conn.prepareStatement("UPSERT INTO " + tableName + " VALUES(?, ?, ?)"); + for (int i = 0; i < rowCount; i++) { + stmt.setInt(1, i); + stmt.setInt(2, i); + stmt.setString(3, "val_" + i); + stmt.executeUpdate(); + } + conn.commit(); + } + + // Get the standby log dir path before closing the logGroup + Path standByLogDir = logGroup.getOrCreatePeerShardManager().getRootDirectoryPath(); + + // Verify replication log has only data table mutations (no index mutations) + logGroup.close(); + Map> logsByTable = groupLogsByTable(); + dumpTableLogCount(logsByTable); + assertTrue("Replication log should contain data table mutations", + logsByTable.containsKey(tableName)); + assertFalse("Replication log should NOT contain index table mutations", + logsByTable.containsKey(indexName)); - private void findLogFilesRecursive(Path dir, FileSystem fs, List files) throws IOException { - if (!fs.exists(dir)) { - return; + // Debug: dump cell timestamps from first deserialized mutation + List dataMutations = logsByTable.get(tableName); + if (dataMutations != null && !dataMutations.isEmpty()) { + Mutation firstMut = dataMutations.get(0); + LOG.info("First mutation type={} ts={}", firstMut.getClass().getSimpleName(), + firstMut.getTimestamp()); + for (Map.Entry> entry : firstMut.getFamilyCellMap().entrySet()) { + for (Cell cell : entry.getValue()) { + LOG.info(" Cell: cf={} qual={} ts={} type={}", + Bytes.toStringBinary(CellUtil.cloneFamily(cell)), + Bytes.toStringBinary(CellUtil.cloneQualifier(cell)), cell.getTimestamp(), + cell.getType()); + } + } } - for (FileStatus status : fs.listStatus(dir)) { - if (status.isDirectory()) { - findLogFilesRecursive(status.getPath(), fs, files); - } else if (status.getPath().getName().endsWith(".plog")) { - files.add(status.getPath()); + + // Create the same table and index on cluster 2 + try (Connection conn2 = CLUSTERS.getCluster2Connection(haGroup)) { + conn2.createStatement().execute( + String.format("CREATE TABLE IF NOT EXISTS %s (ID1 INTEGER NOT NULL, ID2 INTEGER NOT NULL, " + + "VAL1 VARCHAR CONSTRAINT PK PRIMARY KEY (ID1, ID2))", tableName)); + conn2.createStatement() + .execute(String.format("CREATE INDEX IF NOT EXISTS %s ON %s (VAL1)", indexName, tableName)); + conn2.commit(); + } + + // Replay the replication log on cluster 2 + FileSystem fs = standByLogDir.getFileSystem(conf2); + List logFiles = findLogFiles(standByLogDir, fs); + LOG.info("Found {} log files to replay", logFiles.size()); + assertTrue("Should have at least one log file", logFiles.size() > 0); + + ReplicationLogProcessor processor = ReplicationLogProcessor.get(conf2, haGroupName); + try { + for (Path logFile : logFiles) { + LOG.info("Replaying log file: {}", logFile); + processor.processLogFile(fs, logFile); + } + } finally { + processor.close(); + } + + try (Connection conn1 = CLUSTERS.getCluster1Connection(haGroup); + Statement stmt = conn1.createStatement()) { + // Query the data table + try (ResultSet rs = stmt.executeQuery("SELECT /*+ NO_INDEX */ COUNT(*) FROM " + tableName)) { + assertTrue(rs.next()); + assertEquals("Data table on cluster 1 should have all rows", rowCount, rs.getInt(1)); + } + + try (ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM " + tableName)) { + PhoenixResultSet prs = rs.unwrap(PhoenixResultSet.class); + String explainPlan = QueryUtil.getExplainPlan(prs.getUnderlyingIterator()); + assertTrue(explainPlan.contains(indexName)); + assertTrue(rs.next()); + assertEquals("Index table on cluster 1 should have all rows", rowCount, rs.getInt(1)); } } + + // Verify the index table on cluster 2 has data (generated by IRO during replay) + try (Connection conn2 = CLUSTERS.getCluster2Connection(haGroup); + Statement stmt = conn2.createStatement()) { + // Query the data table + try (ResultSet rs = stmt.executeQuery("SELECT /*+ NO_INDEX */ COUNT(*) FROM " + tableName)) { + assertTrue(rs.next()); + assertEquals("Data table on cluster 2 should have all rows", rowCount, rs.getInt(1)); + } + + try (ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM " + tableName)) { + PhoenixResultSet prs = rs.unwrap(PhoenixResultSet.class); + String explainPlan = QueryUtil.getExplainPlan(prs.getUnderlyingIterator()); + assertTrue(explainPlan.contains(indexName)); + assertTrue(rs.next()); + assertEquals("Index table on cluster 2 should have all rows", rowCount, rs.getInt(1)); + } + } + + // Deep cell-level comparison of data and index tables across clusters + assertTablesEqualAcrossClusters(tableName); + assertTablesEqualAcrossClusters(indexName); } - private void assertTablesEqualAcrossClusters(String hbaseTableName) throws Exception { - TableName tn = TableName.valueOf(hbaseTableName); - try ( - org.apache.hadoop.hbase.client.Connection hconn1 = ConnectionFactory.createConnection(conf1); - org.apache.hadoop.hbase.client.Connection hconn2 = ConnectionFactory.createConnection(conf2); - Table table1 = hconn1.getTable(tn); Table table2 = hconn2.getTable(tn)) { - - Scan scan = new Scan(); - scan.readAllVersions(); - - try (ResultScanner scanner1 = table1.getScanner(scan); - ResultScanner scanner2 = table2.getScanner(scan)) { - int rowCount = 0; - while (true) { - Result r1 = scanner1.next(); - Result r2 = scanner2.next(); - if (r1 == null && r2 == null) { - break; - } - assertNotNull( - String.format("Table %s: cluster 2 has fewer rows at row %d", hbaseTableName, rowCount), - r2); - assertNotNull( - String.format("Table %s: cluster 1 has fewer rows at row %d", hbaseTableName, rowCount), - r1); - try { - Result.compareResults(r1, r2, true); - } catch (Exception e) { - LOG.error("Table {} row {} mismatch. Dumping both tables:", hbaseTableName, rowCount); - LOG.error("--- Cluster 1 ---"); - TestUtil.dumpTable(table1); - LOG.error("--- Cluster 2 ---"); - TestUtil.dumpTable(table2); - fail(String.format("Table %s row %d mismatch: %s", hbaseTableName, rowCount, - e.getMessage())); + /** + * Concurrent same-row writes on the active (modeled on + * {@code ConditionalTTLExpressionIT#testConcurrentUpserts}): many threads hammer a small row set + * with randomized null/value columns, so the active produces a large, interleaved stream of + * overlapping batches -- the same data row updated at many timestamps, often within a single + * coalesced standby mini-batch. Replaying that stream on the standby exercises the + * per-{@code (row, + * ts)} grouping under contention: each group must fold only its own pre-image and cells, with no + * leak across rows or timestamps. The active's outcome is nondeterministic, so the invariant is + * cross-cluster cell equality -- the standby must reproduce exactly whatever the active + * committed, for both the data table and the global index. + */ + @Test + public void testConcurrentUpserts() throws Exception { + final String tableName = "T_" + generateUniqueName(); + final String indexName = "I_" + generateUniqueName(); + String createTableDdl = + String.format("create table if not exists %s (id1 integer not null, id2 integer not null, " + + "val1 varchar, val2 varchar constraint pk primary key (id1, id2))", tableName); + String createIndexDdl = String + .format("create index if not exists %s on %s (val1) include (val2)", indexName, tableName); + + try (FailoverPhoenixConnection conn = (FailoverPhoenixConnection) DriverManager + .getConnection(CLUSTERS.getJdbcHAUrl(), clientProps)) { + conn.createStatement().execute(createTableDdl); + conn.createStatement().execute(createIndexDdl); + conn.commit(); + } + + runConcurrentUpsertWorkload(tableName); + + // The global index physical table must receive zero replication records. + Map expected = Maps.newHashMap(); + expected.put(SYSTEM_CATALOG_NAME, 0); + expected.put(SYSTEM_CHILD_LINK_NAME, 0); + expected.put(indexName, 0); + verifyReplication(expected); + + // Replay the per-round log files concurrently to simulate multiple region servers draining + // shard files in parallel within the same round. + replayAndVerifyAcrossClusters(4, Arrays.asList(createTableDdl, createIndexDdl), tableName, + indexName); + } + + /** + * Local-index counterpart to {@link #testConcurrentUpserts}: the same concurrent same-row + * workload, but the table carries only a local index. This drives the {@code PreImageLocalTable} + * replay path under contention -- overlapping batches for one row spread across several round log + * files, then replayed in parallel. Each thread randomizes the indexed column {@code val1}, so a + * large fraction of updates move the local-index row key and must emit an old-key + * {@code DeleteFamily} tombstone built from that group's own pre-image; getting the + * per-{@code (row, ts)} grouping wrong would either leak a stale index row or drop a tombstone. + * The active's outcome is nondeterministic, so the invariant is cross-cluster cell equality: the + * standby must reproduce exactly what the active committed. Local-index cells live in the data + * table's own {@code L#0} family, so verifying the data table cross-cluster also verifies the + * regenerated local index. + */ + @Test + public void testConcurrentUpsertsLocalIndex() throws Exception { + final String tableName = "T_" + generateUniqueName(); + final String localIndexName = "L_" + generateUniqueName(); + String createTableDdl = + String.format("create table if not exists %s (id1 integer not null, id2 integer not null, " + + "val1 varchar, val2 varchar constraint pk primary key (id1, id2))", tableName); + String createLocalIndexDdl = String.format( + "create local index if not exists %s on %s (val1) include (val2)", localIndexName, tableName); + + try (FailoverPhoenixConnection conn = (FailoverPhoenixConnection) DriverManager + .getConnection(CLUSTERS.getJdbcHAUrl(), clientProps)) { + conn.createStatement().execute(createTableDdl); + conn.createStatement().execute(createLocalIndexDdl); + conn.commit(); + } + + runConcurrentUpsertWorkload(tableName); + + // Replay the per-round log files concurrently to simulate multiple region servers draining + // shard files in parallel within the same round. The data table scan covers the L#0 local-index + // family, so this verifies the regenerated local index too. A local index shares the data + // region, so there is no separate index physical table to assert zero replication records on -- + // cross-cluster equality is the whole check. + replayAndVerifyAcrossClusters(4, Arrays.asList(createTableDdl, createLocalIndexDdl), tableName); + } + + /** + * Drives the concurrent same-row upsert workload shared by {@link #testConcurrentUpserts} and + * {@link #testConcurrentUpsertsLocalIndex} against a table with columns {@code (id1, id2, val1, + * val2)}. Eight threads hammer a 20-row set with randomized null/value columns (the indexed + * column {@code val1} included, so index-key-moving updates and old-key tombstones both occur). + * The workload runs by wall clock rather than a fixed iteration count: a short burst would land + * in one 5s replication round, leaving a single populated log file and no concurrent same-row + * replay; committing continuously for several rounds spreads the same rows across several files, + * which is what makes the caller's parallel replay do real overlapping work. Fails the calling + * test if any thread errors or the workload does not finish within the timeout. + */ + private void runConcurrentUpsertWorkload(String tableName) throws InterruptedException { + final int nThreads = 8; + final int batchSize = 50; + final int nRows = 20; + final long deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(25); + final CountDownLatch doneSignal = new CountDownLatch(nThreads); + final AtomicReference firstError = new AtomicReference<>(); + for (int t = 0; t < nThreads; t++) { + final int seed = t; + Thread thread = new Thread(() -> { + Random rand = new Random(seed); + try ( + FailoverPhoenixConnection conn = (FailoverPhoenixConnection) DriverManager + .getConnection(CLUSTERS.getJdbcHAUrl(), clientProps); + PreparedStatement ps = + conn.prepareStatement("upsert into " + tableName + " VALUES(?, ?, ?, ?)")) { + int i = 0; + while (System.currentTimeMillis() < deadline) { + ps.setInt(1, i % nRows); + ps.setInt(2, 0); + ps.setString(3, rand.nextBoolean() ? null : "v1_" + rand.nextInt(nRows)); + ps.setString(4, rand.nextBoolean() ? null : "v2_" + rand.nextInt()); + ps.executeUpdate(); + if ((i % batchSize) == 0) { + conn.commit(); + } + i++; } - rowCount++; + conn.commit(); + } catch (Throwable e) { + firstError.compareAndSet(null, e); + } finally { + doneSignal.countDown(); } - LOG.info("Table {} matches across clusters: {} rows verified", hbaseTableName, rowCount); - } + }); + thread.start(); } + assertTrue("Ran out of time waiting for concurrent upserts", + doneSignal.await(120, TimeUnit.SECONDS)); + if (firstError.get() != null) { + throw new AssertionError("A concurrent upsert thread failed", firstError.get()); + } + } + + private PhoenixTestBuilder.SchemaBuilder createViewHierarchy() throws Exception { + // Define the test schema. + // 1. Table with columns => (ORG_ID, KP, COL1, COL2, COL3), PK => (ORG_ID, KP) + // 2. GlobalView with columns => (ID, COL4, COL5, COL6), PK => (ID) + // 3. Tenant with columns => (ZID, COL7, COL8, COL9), PK => (ZID) + final PhoenixTestBuilder.SchemaBuilder schemaBuilder = + new PhoenixTestBuilder.SchemaBuilder(CLUSTERS.getJdbcHAUrl()); + PhoenixTestBuilder.SchemaBuilder.ConnectOptions connectOptions = + new PhoenixTestBuilder.SchemaBuilder.ConnectOptions(); + connectOptions.setConnectProps(clientProps); + PhoenixTestBuilder.SchemaBuilder.TableOptions tableOptions = + PhoenixTestBuilder.SchemaBuilder.TableOptions.withDefaults(); + PhoenixTestBuilder.SchemaBuilder.GlobalViewOptions globalViewOptions = + PhoenixTestBuilder.SchemaBuilder.GlobalViewOptions.withDefaults(); + PhoenixTestBuilder.SchemaBuilder.TenantViewOptions tenantViewWithOverrideOptions = + PhoenixTestBuilder.SchemaBuilder.TenantViewOptions.withDefaults(); + PhoenixTestBuilder.SchemaBuilder.TenantViewIndexOptions tenantViewIndexOverrideOptions = + PhoenixTestBuilder.SchemaBuilder.TenantViewIndexOptions.withDefaults(); + schemaBuilder.withConnectOptions(connectOptions).withTableOptions(tableOptions) + .withGlobalViewOptions(globalViewOptions).withTenantViewOptions(tenantViewWithOverrideOptions) + .withTenantViewIndexOptions(tenantViewIndexOverrideOptions).buildWithNewTenant(); + return schemaBuilder; } } diff --git a/phoenix-core/src/it/java/org/apache/phoenix/replication/reader/ReplicationLogProcessorTestIT.java b/phoenix-core/src/it/java/org/apache/phoenix/replication/reader/ReplicationLogProcessorTestIT.java index f3edb027f87..089bde4d473 100644 --- a/phoenix-core/src/it/java/org/apache/phoenix/replication/reader/ReplicationLogProcessorTestIT.java +++ b/phoenix-core/src/it/java/org/apache/phoenix/replication/reader/ReplicationLogProcessorTestIT.java @@ -29,6 +29,8 @@ import java.io.IOException; import java.sql.Connection; import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.Statement; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -49,6 +51,7 @@ import org.apache.hadoop.hbase.CellBuilderFactory; import org.apache.hadoop.hbase.CellBuilderType; import org.apache.hadoop.hbase.CellUtil; +import org.apache.hadoop.hbase.DoNotRetryIOException; import org.apache.hadoop.hbase.HRegionInfo; import org.apache.hadoop.hbase.NotServingRegionException; import org.apache.hadoop.hbase.TableName; @@ -67,8 +70,11 @@ import org.apache.hadoop.hbase.util.Pair; import org.apache.phoenix.end2end.NeedsOwnMiniClusterTest; import org.apache.phoenix.end2end.ParallelStatsDisabledIT; +import org.apache.phoenix.execute.MutationState; +import org.apache.phoenix.hbase.index.IndexRegionObserver; import org.apache.phoenix.jdbc.PhoenixConnection; import org.apache.phoenix.query.QueryServices; +import org.apache.phoenix.replication.MutationCellGrouper; import org.apache.phoenix.replication.log.LogFile; import org.apache.phoenix.replication.log.LogFileReader; import org.apache.phoenix.replication.log.LogFileReaderContext; @@ -397,6 +403,71 @@ public void testCalculateRetryDelay() throws IOException { smallDelayProcessor.close(); } + /** + * A DoNotRetryIOException is deterministic, so processReplicationLogBatch must fail immediately + * instead of burning batchRetryCount+1 attempts. The exception is wrapped (as the batch client + * wraps it in a RetriesExhaustedException cause), so this also covers the cause-chain walk. + */ + @Test + public void testProcessBatchDoesNotRetryDoNotRetryIOException() throws Exception { + ReplicationLogProcessor spyProcessor = + Mockito.spy(new ReplicationLogProcessor(conf, testHAGroupName)); + try { + assertTrue("Test assumes a non-zero retry budget", spyProcessor.getBatchRetryCount() > 0); + IOException wrapped = + new IOException("outer", new DoNotRetryIOException("deterministic failure")); + Map> failed = + Collections.singletonMap(TableName.valueOf("T"), Collections.emptyList()); + Mockito.doReturn(new ReplicationLogProcessor.ApplyMutationBatchResult(failed, wrapped)) + .when(spyProcessor).applyMutations(Mockito.any()); + + try { + spyProcessor.processReplicationLogBatch( + Collections.singletonMap(TableName.valueOf("T"), Collections.emptyList())); + fail("Should propagate the non-retryable failure"); + } catch (IOException e) { + // Expected. + } + + // Exactly one attempt: no retries for a DoNotRetryIOException. + Mockito.verify(spyProcessor, Mockito.times(1)).applyMutations(Mockito.any()); + } finally { + spyProcessor.close(); + } + } + + /** + * A retryable failure still exhausts the full retry budget: batchRetryCount+1 attempts. + */ + @Test + public void testProcessBatchRetriesRetryableException() throws Exception { + ReplicationLogProcessor spyProcessor = + Mockito.spy(new ReplicationLogProcessor(conf, testHAGroupName)); + try { + IOException wrapped = + new IOException("outer", new NotServingRegionException("transient failure")); + Map> failed = + Collections.singletonMap(TableName.valueOf("T"), Collections.emptyList()); + Mockito.doReturn(new ReplicationLogProcessor.ApplyMutationBatchResult(failed, wrapped)) + .when(spyProcessor).applyMutations(Mockito.any()); + // Avoid real backoff sleeps in the retry loop. + Mockito.doReturn(0L).when(spyProcessor).calculateRetryDelay(Mockito.anyInt()); + + try { + spyProcessor.processReplicationLogBatch( + Collections.singletonMap(TableName.valueOf("T"), Collections.emptyList())); + fail("Should propagate the failure after exhausting retries"); + } catch (IOException e) { + // Expected. + } + + Mockito.verify(spyProcessor, Mockito.times(spyProcessor.getBatchRetryCount() + 1)) + .applyMutations(Mockito.any()); + } finally { + spyProcessor.close(); + } + } + /** * Tests that configuration parameters are properly read and applied. */ @@ -720,7 +791,13 @@ public void testProcessLogFileBatchSizeLogic() throws Exception { new Path(testFolder.newFile("testProcessLogFileBatchSizeLogic").toURI()); final String tableName = "T_" + generateUniqueName(); final int batchSize = 5; - final long batchSizeBytes = 1800; + // Sized so the first batch flushes on bytes (3 small + 1 big mutation) while the second flushes + // on count (5 small). Each reconstructed mutation carries the REPLICATED_MUTATION attribute + // (~120 bytes), so a stamped small mutation is ~464 bytes and the big one ~1024. First batch = + // 3*464+1024 = 2416 (must be >= threshold to flush on bytes); second batch = 5*464 = 2320 (must + // stay < threshold so it flushes on count, not bytes). The threshold must sit in (2320, 2416]; + // 2400 leaves headroom on both sides. + final long batchSizeBytes = 2400; // Create log file with mutations of varying sizes LogFileWriter writer = initLogFileWriter(batchSizeFilePath); @@ -898,6 +975,89 @@ public void testProcessLogFileBatchSizeLogic() throws Exception { spyProcessor.close(); } + /** + * A single record's body is a flat cell stream that reconstructs into multiple mutations of mixed + * type (e.g. a batch that upserts some rows and, on one of those rows, also nulls a column -- a + * same-row Put plus DeleteColumn). The batch size check is deferred to the record boundary, so + * all mutations of one record must land in the same processReplicationLogBatch call even when the + * record's mutation count exceeds the configured batch size. This protects the (row, ts) grouping + * the standby IRO relies on: splitting a same-row Put and Delete across two batches would let + * each fold against the shared pre-image independently instead of together. + */ + @Test + public void testProcessLogFileDoesNotSplitRecordAcrossBatches() throws Exception { + final Path filePath = + new Path(testFolder.newFile("testDoesNotSplitRecordAcrossBatches").toURI()); + final String tableName = "T_" + generateUniqueName(); + // Batch size smaller than the number of mutations in the big record below, so the old in-loop + // flush would have split the record (the rowA Put/Delete straddle the count-2 boundary). + final int batchSize = 2; + + LogFileWriter writer = initLogFileWriter(filePath); + + // Record 0: one record whose flat cell stream mixes Puts and a Delete and reconstructs into 4 + // mutations in cell order: Put(rowZ), Put(rowA), Delete(rowA), Put(rowB). rowA carries a + // same-row + // Put+DeleteColumn group; the Put lands at mutation index 1 and the Delete at index 2, so a + // count-2 flush would separate them. + Put putZ = LogFileTestUtil.newPut("rowZ", 1L, 1); + Put putA = LogFileTestUtil.newPut("rowA", 1L, 2); + Delete deleteA = LogFileTestUtil.newDelete("rowA", 1L, 1); + Put putB = LogFileTestUtil.newPut("rowB", 1L, 1); + List bigRecordMutations = new ArrayList<>(); + Collections.addAll(bigRecordMutations, putZ, putA, deleteA, putB); + List bigRecordCells = new ArrayList<>(); + for (Mutation m : bigRecordMutations) { + bigRecordCells.addAll(LogFileTestUtil.cellsOf(m)); + } + writer.append(tableName, 0, bigRecordCells); + + // Record 1: a single-mutation record that would coalesce onto a following batch. + Put tailPut = LogFileTestUtil.newPut("tailRow", 2L, 1); + writer.append(tableName, 1, LogFileTestUtil.cellsOf(tailPut)); + + writer.close(); + + Configuration testConf = new Configuration(conf); + testConf.setInt(ReplicationLogProcessor.REPLICATION_STANDBY_LOG_REPLAY_BATCH_SIZE, batchSize); + + ReplicationLogProcessor spyProcessor = + Mockito.spy(new ReplicationLogProcessor(testConf, testHAGroupName)); + + List>> capturedArguments = new ArrayList<>(); + Mockito.doAnswer(invocation -> { + Map> originalMap = invocation.getArgument(0); + capturedArguments.add(new HashMap<>(originalMap)); + return null; + }).when(spyProcessor).processReplicationLogBatch(Mockito.any(Map.class)); + + spyProcessor.processLogFile(localFs, filePath); + + TableName expectedTableName = TableName.valueOf(tableName); + + // The big record (4 mutations) crosses the size threshold at its own boundary and flushes as a + // single batch; the tail record forms the second batch. The record is never split, so the + // rowA Put and Delete stay together. + assertEquals("Record boundary should produce exactly 2 batches", 2, capturedArguments.size()); + + List firstBatch = capturedArguments.get(0).get(expectedTableName); + assertNotNull("First batch mutations should not be null", firstBatch); + assertEquals("First batch must hold the whole big record (all 4 mutations together)", 4, + firstBatch.size()); + for (int i = 0; i < bigRecordMutations.size(); i++) { + LogFileTestUtil.assertMutationEquals("First batch mutation " + i + " mismatch", + bigRecordMutations.get(i), firstBatch.get(i)); + } + + List secondBatch = capturedArguments.get(1).get(expectedTableName); + assertNotNull("Second batch mutations should not be null", secondBatch); + assertEquals("Second batch should hold the tail record", 1, secondBatch.size()); + LogFileTestUtil.assertMutationEquals("Second batch mutation mismatch", tailPut, + secondBatch.get(0)); + + spyProcessor.close(); + } + /** * Tests batching logic when processing log files with mutations for multiple tables. */ @@ -1981,4 +2141,162 @@ private Cell cloneCellWithCustomTimestamp(Cell cell, long timestamp) { .setType(cell.getType()) .setValue(cell.getValueArray(), cell.getValueOffset(), cell.getValueLength()).build(); } + + /** + * Out-of-order replay (the original blocker for PHOENIX-7931): two batches touch the same row, + * and the later one is replayed first. The standby regenerates the global index from each batch's + * shipped pre-image rather than from a region scan, so the result is order-independent. Scenario: + *

    + *
  • M1 (ts=100): full row insert {ID=row1, COL_1=A, COL_2=X}, pre-image = empty row
  • + *
  • M2 (ts=200): partial update {ID=row1, COL_1=B} (COL_2 not included), pre-image = {COL_1=A, + * COL_2=X} (the row state the active observed before M2)
  • + *
+ * Replayed out of order (M2 then M1), naive regeneration would read COL_2=null for M2 (the row is + * absent) and produce an inconsistent index entry (B, null). Because M2 carries its own + * pre-image, the standby derives nextState=(B, X) and the index entry for COL_1=B correctly holds + * COL_2=X. + *

+ * The fixture reproduces what the active capture pipeline ships: data cells plus one METAFAMILY + * pre-image cell per row (via {@link IndexRegionObserver#buildPreImageCell}), with the + * replication attribute envelope (empty INDEX_UUID forcing server-side PTable resolution, + * schema/table names). + */ + @Test + public void testOutOfOrderReplayRegeneratesConsistentIndex() throws Exception { + final String tableName = "T_" + generateUniqueName(); + final String indexName = "I_" + generateUniqueName(); + final long ts1 = 100000L; + final long ts2 = 200000L; + + // Create table with index covering COL_1 and including COL_2 + try (Connection conn = getConnection()) { + conn.createStatement().execute(String.format( + "CREATE TABLE %s (ID VARCHAR PRIMARY KEY, COL_1 VARCHAR, COL_2 VARCHAR)", tableName)); + conn.createStatement().execute( + String.format("CREATE INDEX %s ON %s (COL_1) INCLUDE (COL_2)", indexName, tableName)); + conn.commit(); + } + + // Generate M1: full row insert at ts=100 + List m1Mutations; + try (Connection conn = getConnection()) { + PhoenixConnection pconn = conn.unwrap(PhoenixConnection.class); + pconn.createStatement() + .execute(String.format("UPSERT INTO %s VALUES ('row1', 'A', 'X')", tableName)); + m1Mutations = extractMutationsWithTimestamp(pconn, ts1); + } + + // Generate M2: partial update at ts=200 (only COL_1, no COL_2) + List m2Mutations; + Put m2PreImage; + try (Connection conn = getConnection()) { + PhoenixConnection pconn = conn.unwrap(PhoenixConnection.class); + pconn.createStatement() + .execute(String.format("UPSERT INTO %s (ID, COL_1) VALUES ('row1', 'B')", tableName)); + m2Mutations = extractMutationsWithTimestamp(pconn, ts2); + // M2's pre-image is the full row state M1 wrote: {COL_1=A, COL_2=X}. This is what the active + // observed at lock time and shipped alongside M2. + m2PreImage = new Put(Bytes.toBytes("row1")); + for (Mutation m : m1Mutations) { + for (Cell cell : LogFileTestUtil.cellsOf(m)) { + m2PreImage.add(cell); + } + } + } + + // Write M1 to file1 (pre-image = empty row), M2 to file2 (pre-image = M1's state) + Path file1 = new Path(testFolder.newFile("file1.plog").toURI()); + Path file2 = new Path(testFolder.newFile("file2.plog").toURI()); + appendReplicatedBatch(file1, tableName, m1Mutations, null); + appendReplicatedBatch(file2, tableName, m2Mutations, m2PreImage); + + // Replay out of order: file2 (M2, ts=200) first, then file1 (M1, ts=100) + ReplicationLogProcessor processor = ReplicationLogProcessor.get(conf, testHAGroupName); + try { + processor.processLogFile(localFs, file2); + processor.processLogFile(localFs, file1); + } finally { + processor.close(); + } + + // Verify data table: correct due to HBase cell versioning. + // Final state is COL_1=B (ts=200 wins) and COL_2=X (ts=100, only version). + try (Connection conn = getConnection(); Statement stmt = conn.createStatement()) { + ResultSet rs = stmt + .executeQuery(String.format("SELECT COL_1, COL_2 FROM %s WHERE ID = 'row1'", tableName)); + assertTrue("Should have a row", rs.next()); + assertEquals("Data table COL_1 should be B (ts=200 wins)", "B", rs.getString(1)); + assertEquals("Data table COL_2 should be X (from M1)", "X", rs.getString(2)); + rs.close(); + } + + // Verify index table via index hint: the regenerated entry for COL_1=B must carry COL_2=X, + // matching the data table, because M2's own pre-image supplied COL_2 even though M2 was + // replayed + // first (when the row was absent). + try (Connection conn = getConnection(); Statement stmt = conn.createStatement()) { + ResultSet rs = stmt + .executeQuery(String.format("SELECT /*+ INDEX(%s %s) */ COL_2 FROM %s WHERE COL_1 = 'B'", + tableName, indexName, tableName)); + assertTrue("Index should have an entry for COL_1=B", rs.next()); + assertEquals("Index COL_2 should be X (consistent with data table)", "X", rs.getString(1)); + rs.close(); + } + } + + /** + * Append one replicated batch to a fresh log file, reproducing the active capture pipeline's wire + * format: each record's cell stream is the mutation's data cells plus one METAFAMILY pre-image + * cell, and the record carries the replication attribute envelope (empty INDEX_UUID + + * schema/table names). A {@code null} {@code preImage} encodes the empty-row sentinel. + */ + private void appendReplicatedBatch(Path file, String tableName, List mutations, + Put preImage) throws IOException { + List cells = MutationCellGrouper.buildReplicatedCells(mutations, preImage); + Mutation attrCarrier = mutations.get(0); + attrCarrier.setAttribute(MutationState.MutationMetadataType.SCHEMA_NAME.toString(), + Bytes.toBytes("")); + attrCarrier.setAttribute(MutationState.MutationMetadataType.LOGICAL_TABLE_NAME.toString(), + Bytes.toBytes(tableName)); + Map attrs = MutationCellGrouper.extractReplicationAttributes(attrCarrier); + MutationCellGrouper.stampIndexAttribute(attrs); + LogFileWriter writer = initLogFileWriter(file); + try { + writer.append(tableName, -1, cells, attrs); + writer.sync(); + } finally { + writer.close(); + } + } + + private List extractMutationsWithTimestamp(PhoenixConnection pconn, long timestamp) + throws Exception { + List result = new ArrayList<>(); + Iterator>> iterator = pconn.getMutationState().toMutations(); + while (iterator.hasNext()) { + Pair> pair = iterator.next(); + for (Mutation mutation : pair.getSecond()) { + if (mutation instanceof Put) { + Put put = (Put) mutation; + Put newPut = new Put(put.getRow()); + newPut.setTimestamp(timestamp); + for (Cell cell : put.getFamilyCellMap().values().stream().flatMap(List::stream) + .collect(Collectors.toList())) { + newPut.add(cloneCellWithCustomTimestamp(cell, timestamp)); + } + result.add(newPut); + } else if (mutation instanceof Delete) { + Delete delete = (Delete) mutation; + Delete newDelete = new Delete(delete.getRow()); + newDelete.setTimestamp(timestamp); + for (Cell cell : delete.getFamilyCellMap().values().stream().flatMap(List::stream) + .collect(Collectors.toList())) { + newDelete.add(cloneCellWithCustomTimestamp(cell, timestamp)); + } + result.add(newDelete); + } + } + } + return result; + } } diff --git a/phoenix-core/src/test/java/org/apache/phoenix/hbase/index/IndexRegionObserverReplayTest.java b/phoenix-core/src/test/java/org/apache/phoenix/hbase/index/IndexRegionObserverReplayTest.java new file mode 100644 index 00000000000..651eb54dde1 --- /dev/null +++ b/phoenix-core/src/test/java/org/apache/phoenix/hbase/index/IndexRegionObserverReplayTest.java @@ -0,0 +1,303 @@ +/* + * 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.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.apache.hadoop.hbase.CellUtil; +import org.apache.hadoop.hbase.DoNotRetryIOException; +import org.apache.hadoop.hbase.HConstants; +import org.apache.hadoop.hbase.client.Delete; +import org.apache.hadoop.hbase.client.Mutation; +import org.apache.hadoop.hbase.client.Put; +import org.apache.hadoop.hbase.util.Bytes; +import org.junit.Test; + +/** + * Unit tests for the standby-side index-regeneration helpers in {@link IndexRegionObserver}: + * {@link IndexRegionObserver#decodePreImage}, {@link IndexRegionObserver#applyDeleteToPut}, + * {@link IndexRegionObserver#deriveNextState}, and + * {@link IndexRegionObserver#buildReplicatedRowGroups}. These exercise the (row, ts) fold that + * recovers the active-side {@code nextDataRowState} from the per-row PRE_IMAGE plus the replicated + * cell stream. The reader/reconstruct side (peeling the PRE_IMAGE cell off the wire and stamping + * the attribute) is covered separately by {@code MutationCellGrouperTest}. + */ +public class IndexRegionObserverReplayTest { + + private static final byte[] ROW = Bytes.toBytes("r1"); + private static final byte[] R2 = Bytes.toBytes("r2"); + private static final byte[] CF = Bytes.toBytes("0"); + private static final byte[] Q1 = Bytes.toBytes("c1"); + private static final byte[] Q2 = Bytes.toBytes("c2"); + private static final byte[] Q3 = Bytes.toBytes("c3"); + private static final long TS = 100L; + + private final IndexRegionObserver iro = new IndexRegionObserver("T"); + + /** A Put on the given row at the given ts, one cell per qualifier. */ + private static Put putRowTs(byte[] row, long ts, byte[]... qualifiers) { + Put put = new Put(row); + for (byte[] q : qualifiers) { + put.addColumn(CF, q, ts, Bytes.toBytes("v")); + } + return put; + } + + /** Stamps the given pre-image onto a mutation as its PRE_IMAGE attribute and returns it. */ + private static M withPreImage(M m, Put preImage) throws IOException { + m.setAttribute(IndexRegionObserver.PRE_IMAGE, IndexRegionObserver.encodePreImage(preImage)); + return m; + } + + // ---- decodePreImage ---- + + @Test + public void testDecodePreImageMissingAttributeThrows() { + Put m = putRowTs(ROW, TS, Q1); + try { + iro.decodePreImage(m); + fail("expected DoNotRetryIOException when PRE_IMAGE attribute is absent"); + } catch (DoNotRetryIOException expected) { + assertTrue(expected.getMessage().contains(IndexRegionObserver.PRE_IMAGE)); + } catch (IOException e) { + fail("expected DoNotRetryIOException, got " + e); + } + } + + @Test + public void testDecodePreImageEmptySentinelReturnsNull() throws IOException { + Put m = putRowTs(ROW, TS, Q1); + m.setAttribute(IndexRegionObserver.PRE_IMAGE, HConstants.EMPTY_BYTE_ARRAY); + assertNull("zero-length PRE_IMAGE is the 'active saw empty row' sentinel", + iro.decodePreImage(m)); + } + + @Test + public void testDecodePreImageRoundTrip() throws IOException { + Put preImage = putRowTs(ROW, TS, Q1, Q2); + Put carrier = withPreImage(new Put(ROW), preImage); + + Put decoded = iro.decodePreImage(carrier); + assertTrue(decoded.has(CF, Q1)); + assertTrue(decoded.has(CF, Q2)); + assertTrue(CellUtil.matchingRow(decoded.getFamilyCellMap().get(CF).get(0), ROW)); + } + + // ---- applyDeleteToPut ---- + + @Test + public void testApplyDeleteToNullPutIsNull() { + Delete d = new Delete(ROW); + d.addColumns(CF, Q1, TS); + assertNull(IndexRegionObserver.applyDeleteToPut(d, null)); + } + + @Test + public void testApplyDeleteColumnRemovesOnlyThatColumn() { + Put put = putRowTs(ROW, TS, Q1, Q2); + Delete d = new Delete(ROW); + d.addColumns(CF, Q1, TS); + + Put result = IndexRegionObserver.applyDeleteToPut(d, put); + assertFalse("Q1 should be removed", result.has(CF, Q1)); + assertTrue("Q2 should remain", result.has(CF, Q2)); + } + + @Test + public void testApplyDeleteFamilyRemovesWholeFamily() { + Put put = putRowTs(ROW, TS, Q1, Q2); + Delete d = new Delete(ROW); + d.addFamily(CF, TS); + + Put result = IndexRegionObserver.applyDeleteToPut(d, put); + assertNull("removing the only family empties the row", result); + } + + @Test + public void testApplyDeleteEmptiesRowReturnsNull() { + Put put = putRowTs(ROW, TS, Q1); + Delete d = new Delete(ROW); + d.addColumns(CF, Q1, TS); + + assertNull("deleting the last column empties the row", + IndexRegionObserver.applyDeleteToPut(d, put)); + } + + // ---- deriveNextState ---- + + @Test + public void testDeriveNextStateNoPreImageNoPutIsNull() throws IOException { + Delete d = new Delete(ROW); + d.addColumns(CF, Q1, TS); + assertNull("a Delete-only group with no pre-image yields no state", + IndexRegionObserver.deriveNextState(null, Collections. singletonList(d))); + } + + @Test + public void testDeriveNextStatePutOnNullPreImageInsert() throws IOException { + Put put = putRowTs(ROW, TS, Q1); + Put next = IndexRegionObserver.deriveNextState(null, Collections. singletonList(put)); + assertTrue(next.has(CF, Q1)); + } + + @Test + public void testDeriveNextStatePutMergesOntoPreImage() throws IOException { + Put preImage = putRowTs(ROW, TS, Q1); + Put put = putRowTs(ROW, TS, Q2); + Put next = + IndexRegionObserver.deriveNextState(preImage, Collections. singletonList(put)); + assertTrue("new column present", next.has(CF, Q2)); + assertTrue("pre-image column carried forward", next.has(CF, Q1)); + } + + @Test + public void testDeriveNextStatePutThenDelete() throws IOException { + Put preImage = putRowTs(ROW, TS, Q1); + Put put = putRowTs(ROW, TS, Q2); + Delete del = new Delete(ROW); + del.addColumns(CF, Q1, TS); + + List group = Arrays. asList(put, del); + Put next = IndexRegionObserver.deriveNextState(preImage, group); + assertFalse("Q1 deleted", next.has(CF, Q1)); + assertTrue("Q2 added", next.has(CF, Q2)); + } + + @Test + public void testDeriveNextStateDeleteEmptiesRow() throws IOException { + Put preImage = putRowTs(ROW, TS, Q1); + Delete del = new Delete(ROW); + del.addFamily(CF, TS); + + Put next = + IndexRegionObserver.deriveNextState(preImage, Collections. singletonList(del)); + assertNull("deleting the family empties the derived state", next); + } + + // ---- buildReplicatedRowGroups ---- + + @Test + public void testBuildReplicatedRowGroupsSplitsByTimestamp() throws IOException { + Put g1 = withPreImage(putRowTs(ROW, 100L, Q1), null); + Put g2 = withPreImage(putRowTs(ROW, 200L, Q2), putRowTs(ROW, 200L, Q1)); + + List groups = + iro.buildReplicatedRowGroups(Arrays. asList(g1, g2)); + + assertEquals("two distinct (row, ts) groups", 2, groups.size()); + assertEquals(100L, groups.get(0).ts); + assertEquals(200L, groups.get(1).ts); + assertNull("first group's pre-image is the empty-row sentinel", groups.get(0).preImage); + assertTrue("second group's pre-image carries Q1", groups.get(1).preImage.has(CF, Q1)); + } + + @Test + public void testBuildReplicatedRowGroupsEachGroupKeepsItsOwnPreImage() throws IOException { + // Two batches on the same row: each ships its own authoritative pre-image. The second group's + // pre-image must NOT be derived from the first group — it is the active's shipped value. + Put g1 = withPreImage(putRowTs(ROW, 100L, Q1), null); + Put g2 = withPreImage(putRowTs(ROW, 200L, Q2), putRowTs(ROW, 200L, Q1)); + + List groups = + iro.buildReplicatedRowGroups(Arrays. asList(g1, g2)); + + // group 1: no pre-image + a Put(Q1) -> next has Q1 + assertTrue(groups.get(0).nextState.has(CF, Q1)); + // group 2: pre-image(Q1) + Put(Q2) -> next has both + assertTrue(groups.get(1).nextState.has(CF, Q1)); + assertTrue(groups.get(1).nextState.has(CF, Q2)); + } + + @Test + public void testBuildReplicatedRowGroupsMergesSameRowTs() throws IOException { + Put a = withPreImage(putRowTs(ROW, 100L, Q1), null); + Put b = withPreImage(putRowTs(ROW, 100L, Q2), null); + + List groups = + iro.buildReplicatedRowGroups(Arrays. asList(a, b)); + + assertEquals("same (row, ts) collapses into one group", 1, groups.size()); + assertEquals(2, groups.get(0).mutations.size()); + assertTrue(groups.get(0).nextState.has(CF, Q1)); + assertTrue(groups.get(0).nextState.has(CF, Q2)); + } + + /** + * The canonical (row, ts) grouping case: one mini-batch carrying four mutations across three + * groups -- (R1, ts1) holds a Put and a Delete, (R1, ts2) a Put on the same row at a later ts, + * (R2, ts1) a Put on a different row. Proves the groups are isolated: each folds only its own + * cells onto its own pre-image, and the (R1, ts1) Put+Delete fold does not leak into (R1, ts2) or + * (R2, ts1). + */ + @Test + public void testBuildReplicatedRowGroupsMultiRowMultiTsIsolation() throws IOException { + long ts1 = 100L, ts2 = 200L; + + // (R1, ts1): pre-image {Q1, Q3}; Put A adds Q2, Delete C removes Q3 -> next {Q1, Q2}. + Put r1t1Put = withPreImage(putRowTs(ROW, ts1, Q2), putRowTs(ROW, ts1, Q1, Q3)); + Delete r1t1Del = new Delete(ROW); + r1t1Del.addColumns(CF, Q3, ts1); + withPreImage(r1t1Del, putRowTs(ROW, ts1, Q1, Q3)); + // (R1, ts2): pre-image {Q1}; Put B adds Q3 -> next {Q1, Q3}. + Put r1t2Put = withPreImage(putRowTs(ROW, ts2, Q3), putRowTs(ROW, ts2, Q1)); + // (R2, ts1): no pre-image; Put X adds Q1 -> next {Q1}. + Put r2t1Put = withPreImage(putRowTs(R2, ts1, Q1), null); + + List groups = + iro.buildReplicatedRowGroups(Arrays. asList(r1t1Put, r1t1Del, r1t2Put, r2t1Put)); + + // (a) exactly three groups, in first-seen order. + assertEquals("three (row, ts) groups", 3, groups.size()); + IndexRegionObserver.ReplicatedRowGroup g1 = groups.get(0); + IndexRegionObserver.ReplicatedRowGroup g2 = groups.get(1); + IndexRegionObserver.ReplicatedRowGroup g3 = groups.get(2); + + // (d) each group carries its own (row, ts). + assertTrue(Bytes.equals(ROW, g1.row.copyBytesIfNecessary())); + assertEquals(ts1, g1.ts); + assertTrue(Bytes.equals(ROW, g2.row.copyBytesIfNecessary())); + assertEquals(ts2, g2.ts); + assertTrue(Bytes.equals(R2, g3.row.copyBytesIfNecessary())); + assertEquals(ts1, g3.ts); + + // (b) (R1, ts1): both Put A and Delete C folded onto the ts1 pre-image. + assertEquals("R1/ts1 group holds the Put and the Delete", 2, g1.mutations.size()); + assertTrue("Q1 carried from pre-image", g1.nextState.has(CF, Q1)); + assertTrue("Q2 added by Put A", g1.nextState.has(CF, Q2)); + assertFalse("Q3 removed by Delete C", g1.nextState.has(CF, Q3)); + + // (c) (R1, ts2): only Put B on the ts2 pre-image -- no leak from the ts1 group. + assertTrue("Q1 carried from ts2 pre-image", g2.nextState.has(CF, Q1)); + assertTrue("Q3 added by Put B", g2.nextState.has(CF, Q3)); + assertFalse("Q2 must not leak in from the ts1 group", g2.nextState.has(CF, Q2)); + + // (R2, ts1): independent row, no pre-image -- only Put X's Q1. + assertNull("R2 group has the empty-row sentinel pre-image", g3.preImage); + assertTrue("Q1 inserted by Put X", g3.nextState.has(CF, Q1)); + assertFalse("no leak from R1 groups", g3.nextState.has(CF, Q2)); + assertFalse("no leak from R1 groups", g3.nextState.has(CF, Q3)); + } +} diff --git a/phoenix-core/src/test/java/org/apache/phoenix/replication/MutationCellGrouperTest.java b/phoenix-core/src/test/java/org/apache/phoenix/replication/MutationCellGrouperTest.java index 684fdebcd7c..96dc60c0d01 100644 --- a/phoenix-core/src/test/java/org/apache/phoenix/replication/MutationCellGrouperTest.java +++ b/phoenix-core/src/test/java/org/apache/phoenix/replication/MutationCellGrouperTest.java @@ -17,194 +17,465 @@ */ package org.apache.phoenix.replication; +import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; +import java.sql.Connection; +import java.sql.DriverManager; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; import java.util.List; +import java.util.Map; import org.apache.hadoop.hbase.Cell; import org.apache.hadoop.hbase.CellBuilderFactory; import org.apache.hadoop.hbase.CellBuilderType; import org.apache.hadoop.hbase.CellUtil; +import org.apache.hadoop.hbase.HConstants; import org.apache.hadoop.hbase.client.Delete; import org.apache.hadoop.hbase.client.Mutation; import org.apache.hadoop.hbase.client.Put; import org.apache.hadoop.hbase.util.Bytes; +import org.apache.hadoop.hbase.util.Pair; +import org.apache.hadoop.hbase.wal.WALEdit; +import org.apache.phoenix.execute.MutationState; +import org.apache.phoenix.hbase.index.IndexRegionObserver; +import org.apache.phoenix.index.PhoenixIndexCodec; +import org.apache.phoenix.jdbc.PhoenixConnection; +import org.apache.phoenix.query.BaseConnectionlessQueryTest; import org.junit.Test; /** - * Unit tests for {@link MutationCellGrouper#splitCellsIntoMutations}, the replay-side inverse of - * per-batch cell coalescing. This algorithm is the correctness lynchpin of PHOENIX-7931: a - * regression here would silently merge or split mutations on the standby with no exception, so the - * row+type boundary behavior is pinned here at the unit level rather than only through the - * heavyweight cross-cluster IT. + * Unit tests for {@link MutationCellGrouper}. Real Phoenix UPSERT/DELETE statements are used to + * obtain mutations with the same cell shapes the production write path produces, so the tests + * exercise the row+type boundary algorithm against representative inputs (Put, DeleteFamily, + * DeleteColumn) without manually constructing cells. */ -public class MutationCellGrouperTest { +public class MutationCellGrouperTest extends BaseConnectionlessQueryTest { - private static final byte[] FAMILY = Bytes.toBytes("cf"); - private static final byte[] QUALIFIER = Bytes.toBytes("q"); - private static final long TS = 100L; + private static List getMutations(PhoenixConnection pconn) throws Exception { + List all = new ArrayList<>(); + Iterator>> it = pconn.getMutationState().toMutations(); + while (it.hasNext()) { + all.addAll(it.next().getSecond()); + } + return all; + } + + private static List flatten(List mutations) { + List cells = new ArrayList<>(); + for (Mutation m : mutations) { + cells.addAll(MutationCellGrouper.flattenCells(m)); + } + return cells; + } + + // ---------- splitCellsIntoMutations + flattenCells round-trip ---------- + + @Test + public void testUpsertSingleRowRoundTrip() throws Exception { + try (Connection conn = DriverManager.getConnection(getUrl())) { + conn.setAutoCommit(false); + conn.createStatement() + .execute("CREATE TABLE t (k integer not null primary key, a varchar, b varchar)"); + conn.createStatement().execute("UPSERT INTO t VALUES(1, 'aa', 'bb')"); + List source = getMutations(conn.unwrap(PhoenixConnection.class)); + assertEquals(1, source.size()); + assertTrue(source.get(0) instanceof Put); + + List regrouped = MutationCellGrouper.splitCellsIntoMutations(flatten(source)); + + assertEquals(1, regrouped.size()); + assertTrue(regrouped.get(0) instanceof Put); + assertArrayEquals(source.get(0).getRow(), regrouped.get(0).getRow()); + } + } + + @Test + public void testUpsertMultipleRowsRoundTrip() throws Exception { + try (Connection conn = DriverManager.getConnection(getUrl())) { + conn.setAutoCommit(false); + conn.createStatement().execute("CREATE TABLE t (k integer not null primary key, a varchar)"); + conn.createStatement().execute("UPSERT INTO t VALUES(1, 'aa')"); + conn.createStatement().execute("UPSERT INTO t VALUES(2, 'bb')"); + conn.createStatement().execute("UPSERT INTO t VALUES(3, 'cc')"); + List source = getMutations(conn.unwrap(PhoenixConnection.class)); + assertEquals(3, source.size()); + + List regrouped = MutationCellGrouper.splitCellsIntoMutations(flatten(source)); + + assertEquals(3, regrouped.size()); + for (Mutation m : regrouped) { + assertTrue(m instanceof Put); + } + } + } + + @Test + public void testDeleteRowRoundTrip() throws Exception { + try (Connection conn = DriverManager.getConnection(getUrl())) { + conn.setAutoCommit(false); + conn.createStatement().execute("CREATE TABLE t (k integer not null primary key, a varchar)"); + conn.createStatement().execute("DELETE FROM t WHERE k = 7"); + List source = getMutations(conn.unwrap(PhoenixConnection.class)); + assertEquals(1, source.size()); + assertTrue(source.get(0) instanceof Delete); + + List regrouped = MutationCellGrouper.splitCellsIntoMutations(flatten(source)); - private static Cell putCell(String row, String value) { - return CellBuilderFactory.create(CellBuilderType.DEEP_COPY).setRow(Bytes.toBytes(row)) - .setFamily(FAMILY).setQualifier(QUALIFIER).setTimestamp(TS).setType(Cell.Type.Put) - .setValue(Bytes.toBytes(value)).build(); + assertEquals(1, regrouped.size()); + assertTrue(regrouped.get(0) instanceof Delete); + assertArrayEquals(source.get(0).getRow(), regrouped.get(0).getRow()); + } } - private static Cell deleteColumnCell(String row) { - return CellBuilderFactory.create(CellBuilderType.DEEP_COPY).setRow(Bytes.toBytes(row)) - .setFamily(FAMILY).setQualifier(QUALIFIER).setTimestamp(TS).setType(Cell.Type.DeleteColumn) - .build(); + @Test + public void testMixedUpsertAndDeleteAcrossRows() throws Exception { + try (Connection conn = DriverManager.getConnection(getUrl())) { + conn.setAutoCommit(false); + conn.createStatement().execute("CREATE TABLE t (k integer not null primary key, a varchar)"); + conn.createStatement().execute("UPSERT INTO t VALUES(1, 'aa')"); + conn.createStatement().execute("DELETE FROM t WHERE k = 2"); + conn.createStatement().execute("UPSERT INTO t VALUES(3, 'cc')"); + List source = getMutations(conn.unwrap(PhoenixConnection.class)); + assertEquals(3, source.size()); + + // Assert source shape against row key (Phoenix's MutationState may reorder mutations). + Map> sourceShape = new HashMap<>(); + for (Mutation m : source) { + sourceShape.put(Bytes.toStringBinary(m.getRow()), m.getClass()); + } + + List regrouped = MutationCellGrouper.splitCellsIntoMutations(flatten(source)); + + assertEquals(3, regrouped.size()); + Map> regroupedShape = new HashMap<>(); + for (Mutation m : regrouped) { + regroupedShape.put(Bytes.toStringBinary(m.getRow()), m.getClass()); + } + assertEquals(sourceShape, regroupedShape); + } } - private static Cell deleteFamilyCell(String row) { - return CellBuilderFactory.create(CellBuilderType.DEEP_COPY).setRow(Bytes.toBytes(row)) - .setFamily(FAMILY).setQualifier(QUALIFIER).setTimestamp(TS).setType(Cell.Type.DeleteFamily) - .build(); + /** + * UPSERT that sets a column to NULL produces both Put cells (for the non-null columns and the + * empty value cell) AND a DeleteColumn cell (for the explicit NULL). The grouper splits a mixed + * Put/DeleteColumn cell run for one row into one Put + one Delete, which is the correct + * primary-side shape that the standby applies to the same row in batch order. + */ + @Test + public void testUpsertWithNullProducesPutAndDeleteForSameRow() throws Exception { + try (Connection conn = DriverManager.getConnection(getUrl())) { + conn.setAutoCommit(false); + conn.createStatement().execute( + "CREATE TABLE t (k integer not null primary key, a varchar, b varchar, c varchar)"); + conn.createStatement().execute("UPSERT INTO t VALUES(1, 'aa', NULL, 'cc')"); + List source = getMutations(conn.unwrap(PhoenixConnection.class)); + // Phoenix may produce one Put (with mixed cells) or a Put + a Delete pair; either way + // the row+type boundary algorithm must reproduce the same per-row cell sets after split. + Map> sourceByRow = cellsByRow(source); + List flattenedSource = flatten(source); + + List regrouped = MutationCellGrouper.splitCellsIntoMutations(flattenedSource); + + Map> regroupedByRow = cellsByRow(regrouped); + assertEquals("regrouped row key set must match source", sourceByRow.keySet(), + regroupedByRow.keySet()); + for (String rowKey : sourceByRow.keySet()) { + assertEquals("cell count for row " + rowKey + " must round-trip", + sourceByRow.get(rowKey).size(), regroupedByRow.get(rowKey).size()); + } + + // The point of this test: the mixed Put/DeleteColumn run for the row must split into a Put + // (non-null columns + empty value cell) AND a Delete (the explicit NULL) -- a single merged + // Put with the same total cell count would pass the per-row count check above but be wrong. + String rowKey = sourceByRow.keySet().iterator().next(); + List putsForRow = new ArrayList<>(); + List deletesForRow = new ArrayList<>(); + for (Mutation m : regrouped) { + if (!Bytes.toStringBinary(m.getRow()).equals(rowKey)) { + continue; + } + (m instanceof Delete ? deletesForRow : putsForRow).add(m); + } + assertEquals("row must split into exactly one Put", 1, putsForRow.size()); + assertEquals("row must split into exactly one Delete", 1, deletesForRow.size()); + // Every cell in each split mutation must carry the matching type (no Put cell leaked into the + // Delete, or vice versa), and the two together must account for all the row's cells. + int putCells = MutationCellGrouper.flattenCells(putsForRow.get(0)).size(); + int deleteCells = MutationCellGrouper.flattenCells(deletesForRow.get(0)).size(); + for (Cell c : MutationCellGrouper.flattenCells(putsForRow.get(0))) { + assertTrue("Put must hold only non-delete cells", !CellUtil.isDelete(c)); + } + for (Cell c : MutationCellGrouper.flattenCells(deletesForRow.get(0))) { + assertTrue("Delete must hold only delete cells", CellUtil.isDelete(c)); + } + assertEquals("Put + Delete cells must account for all the row's cells", + sourceByRow.get(rowKey).size(), putCells + deleteCells); + } } - private static String rowOf(Mutation m) { - return Bytes.toString(m.getRow()); + private static Map> cellsByRow(List mutations) { + Map> byRow = new HashMap<>(); + for (Mutation m : mutations) { + String key = Bytes.toStringBinary(m.getRow()); + byRow.computeIfAbsent(key, k -> new ArrayList<>()) + .addAll(MutationCellGrouper.flattenCells(m)); + } + return byRow; } @Test - public void testEmptyInputYieldsEmptyList() throws Exception { - List result = + public void testEmptyInputProducesEmptyOutput() throws Exception { + List regrouped = MutationCellGrouper.splitCellsIntoMutations(Collections. emptyList()); - assertTrue("Empty input should produce no mutations", result.isEmpty()); + assertTrue(regrouped.isEmpty()); + } + + // ---------- reconstructMutations: pre-image peeling + REPLICATED_MUTATION/PRE_IMAGE ---------- + + private static Cell preImageCell(byte[] row, byte[] value) { + return CellBuilderFactory.create(CellBuilderType.SHALLOW_COPY).setRow(row) + .setFamily(WALEdit.METAFAMILY).setQualifier(IndexRegionObserver.PRE_IMAGE_WAL_QUALIFIER) + .setTimestamp(HConstants.LATEST_TIMESTAMP).setType(Cell.Type.Put).setValue(value).build(); } @Test - public void testSinglePutCell() throws Exception { - Cell cell = putCell("row1", "v1"); - List result = - MutationCellGrouper.splitCellsIntoMutations(Collections.singletonList(cell)); - assertEquals(1, result.size()); - assertTrue("Expected a Put", result.get(0) instanceof Put); - assertEquals("row1", rowOf(result.get(0))); - assertEquals(1, result.get(0).size()); + public void testReconstructPeelsPreImageCellAndStampsAttribute() throws Exception { + try (Connection conn = DriverManager.getConnection(getUrl())) { + conn.setAutoCommit(false); + conn.createStatement().execute("CREATE TABLE t (k integer not null primary key, a varchar)"); + conn.createStatement().execute("UPSERT INTO t VALUES(1, 'aa')"); + List source = getMutations(conn.unwrap(PhoenixConnection.class)); + assertEquals(1, source.size()); + byte[] row = source.get(0).getRow(); + List cells = flatten(source); + // Synthesize a pre-image carrier (just opaque bytes here — production carries PB Put). + byte[] preImageBytes = Bytes.toBytes("OPAQUE_PB_PUT"); + cells.add(preImageCell(row, preImageBytes)); + + List regrouped = + MutationCellGrouper.reconstructMutations(cells, Collections. emptyMap()); + + assertEquals("pre-image cell must not produce its own mutation", 1, regrouped.size()); + assertTrue(regrouped.get(0) instanceof Put); + assertArrayEquals(row, regrouped.get(0).getRow()); + assertArrayEquals(preImageBytes, + regrouped.get(0).getAttribute(IndexRegionObserver.PRE_IMAGE)); + assertNotNull("REPLICATED_MUTATION marker must be stamped on every reconstructed mutation", + regrouped.get(0).getAttribute(IndexRegionObserver.REPLICATED_MUTATION)); + } } @Test - public void testSingleDeleteCell() throws Exception { - Cell cell = deleteColumnCell("row1"); - List result = - MutationCellGrouper.splitCellsIntoMutations(Collections.singletonList(cell)); - assertEquals(1, result.size()); - assertTrue("Expected a Delete", result.get(0) instanceof Delete); - assertEquals("row1", rowOf(result.get(0))); + public void testReconstructEmptyPreImageSentinel() throws Exception { + try (Connection conn = DriverManager.getConnection(getUrl())) { + conn.setAutoCommit(false); + conn.createStatement().execute("CREATE TABLE t (k integer not null primary key, a varchar)"); + conn.createStatement().execute("UPSERT INTO t VALUES(1, 'aa')"); + List source = getMutations(conn.unwrap(PhoenixConnection.class)); + byte[] row = source.get(0).getRow(); + List cells = flatten(source); + cells.add(preImageCell(row, HConstants.EMPTY_BYTE_ARRAY)); + + List regrouped = + MutationCellGrouper.reconstructMutations(cells, Collections. emptyMap()); + + assertEquals(1, regrouped.size()); + byte[] attr = regrouped.get(0).getAttribute(IndexRegionObserver.PRE_IMAGE); + assertNotNull(attr); + assertEquals("empty value sentinel preserved", 0, attr.length); + assertNotNull("REPLICATED_MUTATION marker must be stamped on every reconstructed mutation", + regrouped.get(0).getAttribute(IndexRegionObserver.REPLICATED_MUTATION)); + } } @Test - public void testContiguousCellsSameRowAndTypeFormOneMutation() throws Exception { - List cells = new ArrayList<>(); - cells.add(putCell("row1", "v1")); - cells.add(putCell("row1", "v2")); - cells.add(putCell("row1", "v3")); - List result = MutationCellGrouper.splitCellsIntoMutations(cells); - assertEquals("Same row + same type cells must coalesce into one Put", 1, result.size()); - assertTrue(result.get(0) instanceof Put); - assertEquals(3, result.get(0).size()); + public void testReconstructStampsReplicationAttributesOnEveryMutation() throws Exception { + try (Connection conn = DriverManager.getConnection(getUrl())) { + conn.setAutoCommit(false); + conn.createStatement().execute("CREATE TABLE t (k integer not null primary key, a varchar)"); + conn.createStatement().execute("UPSERT INTO t VALUES(1, 'aa')"); + conn.createStatement().execute("UPSERT INTO t VALUES(2, 'bb')"); + List source = getMutations(conn.unwrap(PhoenixConnection.class)); + assertEquals(2, source.size()); + + Map attrs = new HashMap<>(); + attrs.put(PhoenixIndexCodec.INDEX_UUID, Bytes.toBytes("uuid-1")); + attrs.put(MutationState.MutationMetadataType.SCHEMA_NAME.toString(), Bytes.toBytes("S")); + attrs.put(MutationState.MutationMetadataType.LOGICAL_TABLE_NAME.toString(), + Bytes.toBytes("T")); + + List regrouped = MutationCellGrouper.reconstructMutations(flatten(source), attrs); + + assertEquals(2, regrouped.size()); + for (Mutation m : regrouped) { + for (Map.Entry e : attrs.entrySet()) { + assertArrayEquals("attribute " + e.getKey() + " must be on every mutation", e.getValue(), + m.getAttribute(e.getKey())); + } + assertNotNull("REPLICATED_MUTATION marker must be on every reconstructed mutation", + m.getAttribute(IndexRegionObserver.REPLICATED_MUTATION)); + assertNull("PRE_IMAGE must not be set when no pre-image cell is present", + m.getAttribute(IndexRegionObserver.PRE_IMAGE)); + } + } } @Test - public void testRowChangeStartsNewMutation() throws Exception { - List cells = new ArrayList<>(); - cells.add(putCell("row1", "v1")); - cells.add(putCell("row2", "v2")); - cells.add(putCell("row3", "v3")); - List result = MutationCellGrouper.splitCellsIntoMutations(cells); - assertEquals("Each distinct row should yield its own Put", 3, result.size()); - assertEquals("row1", rowOf(result.get(0))); - assertEquals("row2", rowOf(result.get(1))); - assertEquals("row3", rowOf(result.get(2))); + public void testReconstructAttachesPreImageOnlyToMatchingRow() throws Exception { + try (Connection conn = DriverManager.getConnection(getUrl())) { + conn.setAutoCommit(false); + conn.createStatement().execute("CREATE TABLE t (k integer not null primary key, a varchar)"); + conn.createStatement().execute("UPSERT INTO t VALUES(1, 'aa')"); + conn.createStatement().execute("UPSERT INTO t VALUES(2, 'bb')"); + List source = getMutations(conn.unwrap(PhoenixConnection.class)); + byte[] row1 = source.get(0).getRow(); + byte[] row2 = source.get(1).getRow(); + List cells = flatten(source); + // Only row1 has a pre-image. + byte[] row1PreImage = Bytes.toBytes("PRE-1"); + cells.add(preImageCell(row1, row1PreImage)); + + List regrouped = + MutationCellGrouper.reconstructMutations(cells, Collections. emptyMap()); + + assertEquals(2, regrouped.size()); + for (Mutation m : regrouped) { + assertNotNull("REPLICATED_MUTATION marker must be on every reconstructed mutation", + m.getAttribute(IndexRegionObserver.REPLICATED_MUTATION)); + if (Bytes.equals(m.getRow(), row1)) { + assertArrayEquals(row1PreImage, m.getAttribute(IndexRegionObserver.PRE_IMAGE)); + } else if (Bytes.equals(m.getRow(), row2)) { + assertNull(m.getAttribute(IndexRegionObserver.PRE_IMAGE)); + } + } + } } - /** - * The case that justifies the class: a single row whose Put cells precede its Delete cells (e.g. - * an index row that is rewritten then partially deleted within one server-side batch) must split - * on the put-vs-delete boundary into a separate Put and Delete, never merge into one mutation. - */ + // ---------- extractReplicationAttributes ---------- + @Test - public void testPutThenDeleteSameRowSplitsOnTypeBoundary() throws Exception { - List cells = new ArrayList<>(); - cells.add(putCell("row1", "v1")); - cells.add(deleteColumnCell("row1")); - List result = MutationCellGrouper.splitCellsIntoMutations(cells); - assertEquals("Put and Delete on the same row must be two mutations", 2, result.size()); - assertTrue("First should be the Put", result.get(0) instanceof Put); - assertTrue("Second should be the Delete", result.get(1) instanceof Delete); - assertEquals("row1", rowOf(result.get(0))); - assertEquals("row1", rowOf(result.get(1))); + public void testExtractReplicationAttributesFiltersToWellKnownKeys() throws Exception { + Put p = new Put(Bytes.toBytes("r")); + p.setAttribute(PhoenixIndexCodec.INDEX_UUID, Bytes.toBytes("uuid")); + p.setAttribute(MutationState.MutationMetadataType.SCHEMA_NAME.toString(), Bytes.toBytes("S")); + p.setAttribute("UNRELATED_ATTRIBUTE", Bytes.toBytes("ignore me")); + p.setAttribute(IndexRegionObserver.REPLICATED_MUTATION, Bytes.toBytes("must-not-leak")); + p.setAttribute(IndexRegionObserver.PRE_IMAGE, Bytes.toBytes("also-must-not-leak")); + + Map extracted = MutationCellGrouper.extractReplicationAttributes(p); + + assertNull( + "INDEX_UUID is not part of the extracted envelope; the active stamps an empty UUID " + + "itself, gated on its resolved index maintainers", + extracted.get(PhoenixIndexCodec.INDEX_UUID)); + assertArrayEquals(Bytes.toBytes("S"), + extracted.get(MutationState.MutationMetadataType.SCHEMA_NAME.toString())); + assertNull("non-replication attribute must be filtered out", + extracted.get("UNRELATED_ATTRIBUTE")); + assertNull("REPLICATED_MUTATION is reader-synthesized, must not appear in record attributes", + extracted.get(IndexRegionObserver.REPLICATED_MUTATION)); + assertNull("PRE_IMAGE is reader-synthesized, must not appear in record attributes", + extracted.get(IndexRegionObserver.PRE_IMAGE)); } @Test - public void testPutDeletePutOnThreeRowsYieldsThreeMutations() throws Exception { - List cells = new ArrayList<>(); - cells.add(putCell("rowA", "v1")); - cells.add(deleteColumnCell("rowB")); - cells.add(putCell("rowC", "v3")); - List result = MutationCellGrouper.splitCellsIntoMutations(cells); - assertEquals(3, result.size()); - assertTrue(result.get(0) instanceof Put); - assertTrue(result.get(1) instanceof Delete); - assertTrue(result.get(2) instanceof Put); - assertEquals("rowA", rowOf(result.get(0))); - assertEquals("rowB", rowOf(result.get(1))); - assertEquals("rowC", rowOf(result.get(2))); + public void testExtractReplicationAttributesEmptyForBareMutation() throws Exception { + Put p = new Put(Bytes.toBytes("r")); + Map extracted = MutationCellGrouper.extractReplicationAttributes(p); + assertTrue(extracted.isEmpty()); } /** - * Documents that the boundary is keyed on the exact cell type, not merely put-vs-delete: adjacent - * DeleteColumn and DeleteFamily cells on the same row split into two separate Delete mutations. - * This mirrors HBase's ReplicationSink and is relied on so a future change does not silently - * coalesce distinct delete subtypes. + * A non-indexed table's write carries the schema/table/tenant metadata attributes but no + * {@link PhoenixIndexCodec#INDEX_UUID} (the client only sets INDEX_UUID when index metadata is + * present). The extracted envelope must likewise omit INDEX_UUID: an empty UUID here would push + * the standby down the server-cache resolution branch and fail with INDEX_METADATA_NOT_FOUND + * instead of recognizing the table as non-indexed. */ @Test - public void testAdjacentDeleteSubtypesSameRowSplit() throws Exception { - List cells = new ArrayList<>(); - cells.add(deleteColumnCell("row1")); - cells.add(deleteFamilyCell("row1")); - List result = MutationCellGrouper.splitCellsIntoMutations(cells); - assertEquals("Distinct delete subtypes must split into separate Deletes", 2, result.size()); - assertTrue(result.get(0) instanceof Delete); - assertTrue(result.get(1) instanceof Delete); + public void testExtractReplicationAttributesOmitsIndexUuidForNonIndexedTable() throws Exception { + Put p = new Put(Bytes.toBytes("r")); + p.setAttribute(MutationState.MutationMetadataType.SCHEMA_NAME.toString(), Bytes.toBytes("S")); + p.setAttribute(MutationState.MutationMetadataType.LOGICAL_TABLE_NAME.toString(), + Bytes.toBytes("T")); + + Map extracted = MutationCellGrouper.extractReplicationAttributes(p); + + assertNull("a non-indexed table's mutation carries no INDEX_UUID, so the envelope must not " + + "invent one", extracted.get(PhoenixIndexCodec.INDEX_UUID)); + assertArrayEquals(Bytes.toBytes("S"), + extracted.get(MutationState.MutationMetadataType.SCHEMA_NAME.toString())); + assertArrayEquals(Bytes.toBytes("T"), + extracted.get(MutationState.MutationMetadataType.LOGICAL_TABLE_NAME.toString())); } - /** - * The grouper keys boundaries off the immediately preceding cell only, so a row that recurs - * non-contiguously produces a separate mutation per contiguous run. This encodes the documented - * "global row ordering is not required" contract: rowA appearing twice with rowB between yields - * two rowA mutations, not a merged one. - */ + // ---------- contiguity stress (pre-image cell never breaks groupable runs) ---------- + @Test - public void testNonContiguousSameRowYieldsSeparateMutations() throws Exception { - List cells = new ArrayList<>(); - cells.add(putCell("rowA", "v1")); - cells.add(putCell("rowB", "v2")); - cells.add(putCell("rowA", "v3")); - List result = MutationCellGrouper.splitCellsIntoMutations(cells); - assertEquals(3, result.size()); - assertEquals("rowA", rowOf(result.get(0))); - assertEquals("rowB", rowOf(result.get(1))); - assertEquals("rowA", rowOf(result.get(2))); + public void testPreImageCellInterleavedDoesNotCreateExtraMutation() throws Exception { + try (Connection conn = DriverManager.getConnection(getUrl())) { + conn.setAutoCommit(false); + conn.createStatement().execute("CREATE TABLE t (k integer not null primary key, a varchar)"); + conn.createStatement().execute("UPSERT INTO t VALUES(1, 'aa')"); + conn.createStatement().execute("UPSERT INTO t VALUES(2, 'bb')"); + List source = getMutations(conn.unwrap(PhoenixConnection.class)); + byte[] row1 = source.get(0).getRow(); + byte[] row2 = source.get(1).getRow(); + // Build a stream that interleaves pre-image cells between row buckets: + // [row1.data..., row1.preImage, row2.data..., row2.preImage] + List cells = new ArrayList<>(); + cells.addAll(MutationCellGrouper.flattenCells(source.get(0))); + cells.add(preImageCell(row1, Bytes.toBytes("PRE-1"))); + cells.addAll(MutationCellGrouper.flattenCells(source.get(1))); + cells.add(preImageCell(row2, Bytes.toBytes("PRE-2"))); + + List regrouped = + MutationCellGrouper.reconstructMutations(cells, Collections. emptyMap()); + + assertEquals("pre-image cells must be peeled before grouping", 2, regrouped.size()); + for (Mutation m : regrouped) { + assertTrue(m instanceof Put); + assertNotNull(m.getAttribute(IndexRegionObserver.REPLICATED_MUTATION)); + assertNotNull(m.getAttribute(IndexRegionObserver.PRE_IMAGE)); + } + } } + /** + * Guard against a regression where a pre-image cell that follows a row's last cell of a different + * type might be misgrouped. The test: a Delete row's cells (Type.DeleteFamily) followed by a + * Type.Put pre-image cell. Without peeling, this would split into a Delete plus a stray Put. With + * peeling it must split into exactly one Delete carrying the pre-image attribute. + */ @Test - public void testCellsArePreservedInResultMutations() throws Exception { - Cell put1 = putCell("row1", "v1"); - Cell put2 = putCell("row1", "v2"); - List cells = new ArrayList<>(); - cells.add(put1); - cells.add(put2); - List result = MutationCellGrouper.splitCellsIntoMutations(cells); - assertEquals(1, result.size()); - List grouped = result.get(0).getFamilyCellMap().get(FAMILY); - assertEquals(2, grouped.size()); - assertTrue( - CellUtil.equals(put1, grouped.get(0)) && CellUtil.matchingValue(put1, grouped.get(0))); - assertTrue( - CellUtil.equals(put2, grouped.get(1)) && CellUtil.matchingValue(put2, grouped.get(1))); + public void testPreImageCellAfterDeleteCellsIsPeeled() throws Exception { + try (Connection conn = DriverManager.getConnection(getUrl())) { + conn.setAutoCommit(false); + conn.createStatement().execute("CREATE TABLE t (k integer not null primary key, a varchar)"); + conn.createStatement().execute("DELETE FROM t WHERE k = 5"); + List source = getMutations(conn.unwrap(PhoenixConnection.class)); + assertEquals(1, source.size()); + assertTrue(source.get(0) instanceof Delete); + byte[] row = source.get(0).getRow(); + + List cells = new ArrayList<>(); + cells.addAll(MutationCellGrouper.flattenCells(source.get(0))); + cells.add(preImageCell(row, Bytes.toBytes("PRE-DEL"))); + + List regrouped = + MutationCellGrouper.reconstructMutations(cells, Collections. emptyMap()); + + assertEquals(1, regrouped.size()); + assertTrue(regrouped.get(0) instanceof Delete); + assertArrayEquals(Bytes.toBytes("PRE-DEL"), + regrouped.get(0).getAttribute(IndexRegionObserver.PRE_IMAGE)); + assertNotNull(regrouped.get(0).getAttribute(IndexRegionObserver.REPLICATED_MUTATION)); + } } } 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 25afca09645..d74eab43ba7 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 @@ -41,6 +41,7 @@ import java.io.IOException; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.concurrent.CompletableFuture; @@ -111,15 +112,15 @@ public void testAppendAndSync() throws Exception { // Happens-before ordering verification, using Mockito's inOrder. Verify that the appends // happen before sync, and sync happened after appends. inOrder.verify(writer, times(1)).append(eq(tableName), eq(commitId1), - eq(LogFileTestUtil.cellsOf(put1))); + eq(LogFileTestUtil.cellsOf(put1)), any()); inOrder.verify(writer, times(1)).append(eq(tableName), eq(commitId2), - eq(LogFileTestUtil.cellsOf(put2))); + eq(LogFileTestUtil.cellsOf(put2)), any()); inOrder.verify(writer, times(1)).append(eq(tableName), eq(commitId3), - eq(LogFileTestUtil.cellsOf(put3))); + eq(LogFileTestUtil.cellsOf(put3)), any()); inOrder.verify(writer, times(1)).append(eq(tableName), eq(commitId4), - eq(LogFileTestUtil.cellsOf(put4))); + eq(LogFileTestUtil.cellsOf(put4)), any()); inOrder.verify(writer, times(1)).append(eq(tableName), eq(commitId5), - eq(LogFileTestUtil.cellsOf(put5))); + eq(LogFileTestUtil.cellsOf(put5)), any()); inOrder.verify(writer, times(1)).sync(); } @@ -151,7 +152,7 @@ public void testSyncFailureAndRetry() throws Exception { // Verify the sequence: append, sync (fail), sync (succeed on retry with same writer) InOrder inOrder = Mockito.inOrder(writer); inOrder.verify(writer, times(1)).append(eq(tableName), eq(commitId), - eq(LogFileTestUtil.cellsOf(put))); + eq(LogFileTestUtil.cellsOf(put)), any()); inOrder.verify(writer, times(2)).sync(); // First fails, second succeeds } @@ -176,7 +177,7 @@ public Object answer(InvocationOnMock invocation) throws Throwable { sleep(50); // Simulate slow processing return invocation.callRealMethod(); } - }).when(innerWriter).append(anyString(), anyLong(), any(List.class)); + }).when(innerWriter).append(anyString(), anyLong(), any(List.class), any()); // Fill up the ring buffer by sending enough events. for (int i = 0; i < TEST_RINGBUFFER_SIZE; i++) { @@ -214,7 +215,7 @@ public Object answer(InvocationOnMock invocation) throws Throwable { // Verify the append eventually happens on the writer. verify(innerWriter, timeout(10000).times(1)).append(eq(tableName), eq(myCommitId), - any(List.class)); + any(List.class), any()); } /** @@ -233,7 +234,7 @@ public void testAppendFailureAndRetry() throws Exception { // Configure writerBeforeRoll to fail on the first append call doThrow(new IOException("Simulated append failure")).when(writerBeforeRoll).append(anyString(), - anyLong(), any(List.class)); + anyLong(), any(List.class), any()); // Append data logGroup.append(tableName, commitId, put); @@ -246,10 +247,10 @@ public void testAppendFailureAndRetry() throws Exception { // Verify the sequence: append (fail), rotate, append (succeed), sync InOrder inOrder = Mockito.inOrder(writerBeforeRoll, writerAfterRoll); inOrder.verify(writerBeforeRoll, times(1)).append(eq(tableName), eq(commitId), - eq(LogFileTestUtil.cellsOf(put))); + eq(LogFileTestUtil.cellsOf(put)), any()); inOrder.verify(writerBeforeRoll, times(0)).sync(); // We failed append, did not try inOrder.verify(writerAfterRoll, times(1)).append(eq(tableName), eq(commitId), - eq(LogFileTestUtil.cellsOf(put))); // Retry + eq(LogFileTestUtil.cellsOf(put)), any()); // Retry inOrder.verify(writerAfterRoll, times(1)).sync(); } @@ -358,7 +359,7 @@ public void testConcurrentProducers() throws Exception { // Verify that all of appends were processed by the internal writer. for (int i = 0; i < APPENDS_PER_THREAD * 2; i++) { final long commitId = i; - verify(innerWriter, times(1)).append(eq(tableName), eq(commitId), any(List.class)); + verify(innerWriter, times(1)).append(eq(tableName), eq(commitId), any(List.class), any()); } } @@ -400,10 +401,10 @@ public void testTimeBasedRotation() throws Exception { // Verify the sequence of operations InOrder inOrder = Mockito.inOrder(writerBeforeRotation, writerAfterRotation); inOrder.verify(writerBeforeRotation, times(1)).append(eq(tableName), eq(commitId), - eq(LogFileTestUtil.cellsOf(put))); + eq(LogFileTestUtil.cellsOf(put)), any()); inOrder.verify(writerBeforeRotation, times(1)).sync(); inOrder.verify(writerAfterRotation, times(1)).append(eq(tableName), eq(commitId + 1), - eq(LogFileTestUtil.cellsOf(put))); + eq(LogFileTestUtil.cellsOf(put)), any()); inOrder.verify(writerAfterRotation, times(1)).sync(); } @@ -443,7 +444,7 @@ public void testSizeBasedRotation() throws Exception { // Verify the final append went to the new writer verify(writerAfterRotation, times(1)).append(eq(tableName), eq(commitId), - eq(LogFileTestUtil.cellsOf(put))); + eq(LogFileTestUtil.cellsOf(put)), any()); verify(writerAfterRotation, times(1)).sync(); } @@ -529,11 +530,11 @@ public void testRotationTask() throws Exception { // Verify first batch went to initial writer verify(writerBeforeRotation, times(1)).append(eq(tableName), eq(1L), - eq(LogFileTestUtil.cellsOf(put))); + eq(LogFileTestUtil.cellsOf(put)), any()); verify(writerBeforeRotation, times(1)).sync(); // Verify second batch went to new writer (swap happened before append) verify(writerAfterRotation, times(1)).append(eq(tableName), eq(commitId + 1), - eq(LogFileTestUtil.cellsOf(put))); + eq(LogFileTestUtil.cellsOf(put)), any()); verify(writerAfterRotation, times(1)).sync(); // Verify the initial writer was closed asynchronously verify(writerBeforeRotation, timeout(5000).times(1)).close(); @@ -589,13 +590,13 @@ public void testFailedRotation() throws Exception { // Verify operations went to the writers in the correct order InOrder inOrder = Mockito.inOrder(initialWriter, writerAfterRotate); inOrder.verify(initialWriter).append(eq(tableName), eq(commitId), - eq(LogFileTestUtil.cellsOf(put))); + eq(LogFileTestUtil.cellsOf(put)), any()); inOrder.verify(initialWriter).sync(); inOrder.verify(initialWriter).append(eq(tableName), eq(commitId + 1), - eq(LogFileTestUtil.cellsOf(put))); + eq(LogFileTestUtil.cellsOf(put)), any()); inOrder.verify(initialWriter).sync(); inOrder.verify(writerAfterRotate).append(eq(tableName), eq(commitId + 2), - eq(LogFileTestUtil.cellsOf(put))); + eq(LogFileTestUtil.cellsOf(put)), any()); inOrder.verify(writerAfterRotate).sync(); } @@ -651,7 +652,7 @@ public void testEventProcessingException() throws Exception { // Configure writer to throw a RuntimeException on append doThrow(new RuntimeException("Simulated critical error")).when(innerWriter).append(anyString(), - anyLong(), any(List.class)); + anyLong(), any(List.class), any()); // Append publishes to the ring buffer. The event handler catches the RuntimeException via // catch(Throwable), poisons itself, and fails the sync future. The producer receives @@ -789,12 +790,12 @@ public void testRotationDuringBatch() throws Exception { // 5 appends went to old writer (processed before rotation task fired) for (int i = 0; i < 5; i++) { inOrder.verify(writerBeforeRotation, times(1)).append(eq(tableName), eq(commitId + i), - eq(LogFileTestUtil.cellsOf(put))); + eq(LogFileTestUtil.cellsOf(put)), any()); } // Swap happens before sync action: 5 records replayed into new writer for (int i = 0; i < 5; i++) { inOrder.verify(writerAfterRotation, times(1)).append(eq(tableName), eq(commitId + i), - eq(LogFileTestUtil.cellsOf(put))); + eq(LogFileTestUtil.cellsOf(put)), any()); } // Sync goes to new writer inOrder.verify(writerAfterRotation, times(1)).sync(); @@ -1048,7 +1049,7 @@ public void testAppendAfterCloseOnError() throws Exception { // Configure writer to throw RuntimeException on append doThrow(new RuntimeException("Simulated critical error")).when(innerWriter).append(anyString(), - anyLong(), any(List.class)); + anyLong(), any(List.class), any()); // Append publishes to the ring buffer. The event handler catches the RuntimeException, // poisons itself, and fails the sync future. The producer calls abort(). @@ -1087,7 +1088,7 @@ public void testSyncAfterCloseOnError() throws Exception { // Configure writer to throw RuntimeException on append doThrow(new RuntimeException("Simulated critical error")).when(innerWriter).append(anyString(), - anyLong(), any(List.class)); + anyLong(), any(List.class), any()); // Append publishes to the ring buffer. The event handler catches the RuntimeException, // poisons itself, and fails the sync future. The producer calls abort(). @@ -1194,7 +1195,8 @@ public Object answer(InvocationOnMock invocation) throws Throwable { sleep(50); // Delay to allow multiple events to be posted return invocation.callRealMethod(); } - }).when(innerWriter).append(eq(tableName), eq(commitId1), eq(LogFileTestUtil.cellsOf(put1))); + }).when(innerWriter).append(eq(tableName), eq(commitId1), eq(LogFileTestUtil.cellsOf(put1)), + any()); // Post appends and three syncs in quick succession. The first append will be delayed long // enough for the three syncs to appear in a single Disruptor batch. Then they should all @@ -1210,11 +1212,11 @@ public Object answer(InvocationOnMock invocation) throws Throwable { // one sync. InOrder inOrder = Mockito.inOrder(innerWriter); inOrder.verify(innerWriter, times(1)).append(eq(tableName), eq(commitId1), - eq(LogFileTestUtil.cellsOf(put1))); + eq(LogFileTestUtil.cellsOf(put1)), any()); inOrder.verify(innerWriter, times(1)).append(eq(tableName), eq(commitId2), - eq(LogFileTestUtil.cellsOf(put2))); + eq(LogFileTestUtil.cellsOf(put2)), any()); inOrder.verify(innerWriter, times(1)).append(eq(tableName), eq(commitId3), - eq(LogFileTestUtil.cellsOf(put3))); + eq(LogFileTestUtil.cellsOf(put3)), any()); inOrder.verify(innerWriter, times(1)).sync(); // Only one sync should be called } @@ -1318,12 +1320,12 @@ public void testInFlightAppendsReplayAfterModeSwitch() throws Exception { // invocation/stub state is not thread-safe and the partially-applied stub can be matched // against an unrelated method on the consumer thread. doThrow(new IOException("Simulate append failure")).when(writer).append(tableName, commitId5, - LogFileTestUtil.cellsOf(put5)); + LogFileTestUtil.cellsOf(put5), Collections.emptyMap()); // Rotated writers must also fail on the 5th append so the retry doesn't rescue the loop. doAnswer(invocation -> { LogFileWriter w = (LogFileWriter) invocation.callRealMethod(); doThrow(new IOException("Simulate append failure")).when(w).append(tableName, commitId5, - LogFileTestUtil.cellsOf(put5)); + LogFileTestUtil.cellsOf(put5), Collections.emptyMap()); return w; }).when(activeLog).createNewWriter(); @@ -1341,15 +1343,15 @@ public void testInFlightAppendsReplayAfterModeSwitch() throws Exception { // verify that all the in-flight appends and syncs are replayed on the new store and forward // writer inOrder.verify(storeAndForwardWriter, times(1)).append(eq(tableName), eq(commitId1), - eq(LogFileTestUtil.cellsOf(put1))); + eq(LogFileTestUtil.cellsOf(put1)), any()); inOrder.verify(storeAndForwardWriter, times(1)).append(eq(tableName), eq(commitId2), - eq(LogFileTestUtil.cellsOf(put2))); + eq(LogFileTestUtil.cellsOf(put2)), any()); inOrder.verify(storeAndForwardWriter, times(1)).append(eq(tableName), eq(commitId3), - eq(LogFileTestUtil.cellsOf(put3))); + eq(LogFileTestUtil.cellsOf(put3)), any()); inOrder.verify(storeAndForwardWriter, times(1)).append(eq(tableName), eq(commitId4), - eq(LogFileTestUtil.cellsOf(put4))); + eq(LogFileTestUtil.cellsOf(put4)), any()); inOrder.verify(storeAndForwardWriter, times(1)).append(eq(tableName), eq(commitId5), - eq(LogFileTestUtil.cellsOf(put5))); + eq(LogFileTestUtil.cellsOf(put5)), any()); inOrder.verify(storeAndForwardWriter, times(1)).sync(); } @@ -1415,16 +1417,16 @@ public void testReplayOnMidBatchSwap() throws Exception { // 3 appends went to old writer for (int i = 0; i < 3; i++) { inOrder.verify(writerBeforeRotation, times(1)).append(eq(tableName), eq(commitId + i), - eq(LogFileTestUtil.cellsOf(put))); + eq(LogFileTestUtil.cellsOf(put)), any()); } // 3 records replayed into new writer for (int i = 0; i < 3; i++) { inOrder.verify(writerAfterRotation, times(1)).append(eq(tableName), eq(commitId + i), - eq(LogFileTestUtil.cellsOf(put))); + eq(LogFileTestUtil.cellsOf(put)), any()); } // 4th append goes to new writer inOrder.verify(writerAfterRotation, times(1)).append(eq(tableName), eq(commitId + 3), - eq(LogFileTestUtil.cellsOf(put))); + eq(LogFileTestUtil.cellsOf(put)), any()); inOrder.verify(writerAfterRotation, times(1)).sync(); // Old writer closed async @@ -1467,11 +1469,11 @@ public void testRetryPicksUpStagedWriter() throws Exception { // Old writer: received the append only verify(initialWriter, times(1)).append(eq(tableName), eq(commitId), - eq(LogFileTestUtil.cellsOf(put))); + eq(LogFileTestUtil.cellsOf(put)), any()); // New writer: received replayed append + successful sync verify(newWriter, times(1)).append(eq(tableName), eq(commitId), - eq(LogFileTestUtil.cellsOf(put))); + eq(LogFileTestUtil.cellsOf(put)), any()); verify(newWriter, times(1)).sync(); } @@ -1504,7 +1506,7 @@ public void testIdleLeaseRecoveryDrainsStagedWriter() throws Exception { // Simulate HDFS lease recovery breaking the old writer's stream. Installed while idle — before // forceRotation() publishes the swap event — so the consumer cannot race this stub install. doThrow(new IOException("Simulated broken stream after lease recovery")).when(initialWriter) - .append(anyString(), anyLong(), any(List.class)); + .append(anyString(), anyLong(), any(List.class), any()); doThrow(new IOException("Simulated broken stream after lease recovery")).when(initialWriter) .sync(); @@ -1521,7 +1523,7 @@ public void testIdleLeaseRecoveryDrainsStagedWriter() throws Exception { // New writer received the new append + sync (no replay — currentBatch was empty) verify(newWriter, times(1)).append(eq(tableName), eq(commitId + 1), - eq(LogFileTestUtil.cellsOf(put))); + eq(LogFileTestUtil.cellsOf(put)), any()); verify(newWriter, times(1)).sync(); // Await the async close of the old writer. This happens-after every invocation the swap makes @@ -1530,7 +1532,7 @@ public void testIdleLeaseRecoveryDrainsStagedWriter() throws Exception { // Old writer: only the pre-idle append + sync, nothing after the break verify(initialWriter, times(1)).append(eq(tableName), eq(commitId), - eq(LogFileTestUtil.cellsOf(put))); + eq(LogFileTestUtil.cellsOf(put)), any()); verify(initialWriter, times(1)).sync(); } @@ -1557,7 +1559,7 @@ public void testReplayFailureRetries() throws Exception { throw new IOException("Simulated transient HDFS error during replay"); } return appendInvocation.callRealMethod(); - }).when(w).append(anyString(), anyLong(), any(List.class)); + }).when(w).append(anyString(), anyLong(), any(List.class), any()); return w; }).when(activeLog).createNewWriter(); @@ -1579,11 +1581,11 @@ public void testReplayFailureRetries() throws Exception { // Final writer (W3): replayed r1+r2 then appended r3 — each exactly once. verify(finalWriter, times(1)).append(eq(tableName), eq(commitId), - eq(LogFileTestUtil.cellsOf(put))); + eq(LogFileTestUtil.cellsOf(put)), any()); verify(finalWriter, times(1)).append(eq(tableName), eq(commitId + 1), - eq(LogFileTestUtil.cellsOf(put))); + eq(LogFileTestUtil.cellsOf(put)), any()); verify(finalWriter, times(1)).append(eq(tableName), eq(commitId + 2), - eq(LogFileTestUtil.cellsOf(put))); + eq(LogFileTestUtil.cellsOf(put)), any()); verify(finalWriter, times(1)).sync(); } @@ -1604,7 +1606,7 @@ public void testErrorRecoveryRequestsNewWriter() throws Exception { // Configure initial writer's append to always fail (simulating broken HDFS stream) doThrow(new IOException("Simulated broken stream")).when(initialWriter).append(anyString(), - anyLong(), any(List.class)); + anyLong(), any(List.class), any()); // Append — attempt 1 fails on initialWriter, rotation requested, attempt 2 drains the // rotated writer and succeeds @@ -1616,10 +1618,10 @@ public void testErrorRecoveryRequestsNewWriter() throws Exception { // Old writer received 1 failed attempt verify(initialWriter, times(1)).append(eq(tableName), eq(commitId), - eq(LogFileTestUtil.cellsOf(put))); + eq(LogFileTestUtil.cellsOf(put)), any()); // New writer received the successful append verify(newWriter, times(1)).append(eq(tableName), eq(commitId), - eq(LogFileTestUtil.cellsOf(put))); + eq(LogFileTestUtil.cellsOf(put)), any()); verify(newWriter, times(1)).sync(); } @@ -2120,7 +2122,7 @@ public void testPublishSwapEventOnFullRingBufferIsNoop() throws Exception { doAnswer(invocation -> { holdConsumer.await(); return invocation.callRealMethod(); - }).when(innerWriter).append(anyString(), anyLong(), any(List.class)); + }).when(innerWriter).append(anyString(), anyLong(), any(List.class), any()); Thread filler = new Thread(() -> { try { @@ -2239,7 +2241,7 @@ public void testReplicationSyncPathSimulator() throws Exception { LogFileTestUtil.newPut("row" + commitId + "_" + j, commitId, cellsPerMutation); batchCells.addAll(LogFileTestUtil.cellsOf(put)); } - logGroup.append(tableName, commitId, batchCells); + logGroup.append(tableName, commitId, batchCells, Collections.emptyMap()); totalProducerAppends.incrementAndGet(); } else { for (int j = 0; j < appendsPerSync; j++) { @@ -2364,9 +2366,11 @@ public void testSyncMetricsEmitted() throws Exception { // getCurrentMetricValues() snapshots and resets the histogram bins, so call it exactly once. ReplicationLogMetricValues values = logGroup.getMetrics().getCurrentMetricValues(); - // Nanosecond-resolution metrics never truncate to zero, so assert strictly positive. - assertTrue("appendTime should be > 0, got " + values.getAppendTimeMax(), - values.getAppendTimeMax() > 0); + // appendTime brackets only the uncontended ring-buffer publish -- the shortest interval of all + // these metrics. Nanosecond units are not nanosecond resolution: on a fast machine every + // publish can floor to a 0ns delta, so assert presence (>= 0) rather than strict positivity. + assertTrue("appendTime should be >= 0, got " + values.getAppendTimeMax(), + values.getAppendTimeMax() >= 0); assertTrue("ringBufferTime should be > 0, got " + values.getRingBufferTimeMax(), values.getRingBufferTimeMax() > 0); assertTrue("pendingSyncWaitTime should be > 0, got " + values.getPendingSyncWaitTimeMax(), diff --git a/phoenix-core/src/test/java/org/apache/phoenix/replication/log/LogFileCodecTest.java b/phoenix-core/src/test/java/org/apache/phoenix/replication/log/LogFileCodecTest.java index 98e70aad863..c3dfb162d3b 100644 --- a/phoenix-core/src/test/java/org/apache/phoenix/replication/log/LogFileCodecTest.java +++ b/phoenix-core/src/test/java/org/apache/phoenix/replication/log/LogFileCodecTest.java @@ -38,8 +38,13 @@ import org.apache.hadoop.hbase.HBaseConfiguration; import org.apache.hadoop.hbase.HConstants; import org.apache.hadoop.hbase.client.Delete; +import org.apache.hadoop.hbase.client.Mutation; import org.apache.hadoop.hbase.client.Put; import org.apache.hadoop.hbase.util.Bytes; +import org.apache.phoenix.execute.MutationState; +import org.apache.phoenix.hbase.index.IndexRegionObserver; +import org.apache.phoenix.index.PhoenixIndexCodec; +import org.apache.phoenix.replication.ReplicationLogGroup; import org.junit.Assume; import org.junit.Test; import org.slf4j.Logger; @@ -742,4 +747,98 @@ private static void logFramingMode(String label, BenchResult r, long totalCells) String.format("%.2f", (double) r.appendNs / Math.max(1, totalCells))); } + @Test + public void testMutationAttributesRoundTrip() throws IOException { + long ts = 12345L; + Put put = new Put(Bytes.toBytes("row")); + put.setTimestamp(ts); + put.addColumn(Bytes.toBytes("cf"), Bytes.toBytes("q"), ts, Bytes.toBytes("v")); + put.setAttribute(PhoenixIndexCodec.INDEX_UUID, HConstants.EMPTY_BYTE_ARRAY); + put.setAttribute(MutationState.MutationMetadataType.SCHEMA_NAME.toString(), + Bytes.toBytes("MY_SCHEMA")); + put.setAttribute(MutationState.MutationMetadataType.LOGICAL_TABLE_NAME.toString(), + Bytes.toBytes("MY_TABLE")); + put.setAttribute(MutationState.MutationMetadataType.TENANT_ID.toString(), + Bytes.toBytes("tenant1")); + put.setAttribute(IndexRegionObserver.REPLICATED_MUTATION, HConstants.EMPTY_BYTE_ARRAY); + + LogFile.Record original = + new LogFileRecord().setHBaseTableName("TBLATTR").setCommitId(1L).setMutation(put); + + LogFileCodec codec = new LogFileCodec(); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + codec.getEncoder(new DataOutputStream(baos)).write(original); + LogFile.Codec.Decoder decoder = + codec.getDecoder(new DataInputStream(new ByteArrayInputStream(baos.toByteArray()))); + + assertTrue(decoder.advance()); + Mutation decoded = decoder.current().getMutation(); + + for (String attrKey : ReplicationLogGroup.REPLICATION_ATTR_KEYS) { + byte[] expected = put.getAttribute(attrKey); + byte[] actual = decoded.getAttribute(attrKey); + assertTrue("Attribute " + attrKey + " should be present", actual != null); + assertTrue("Attribute " + attrKey + " value should match", Arrays.equals(expected, actual)); + } + } + + @Test + public void testNoAttributesRoundTrip() throws IOException { + long ts = 12345L; + Put put = new Put(Bytes.toBytes("row")); + put.setTimestamp(ts); + put.addColumn(Bytes.toBytes("cf"), Bytes.toBytes("q"), ts, Bytes.toBytes("v")); + + LogFile.Record original = + new LogFileRecord().setHBaseTableName("TBLNOATTR").setCommitId(1L).setMutation(put); + + LogFileCodec codec = new LogFileCodec(); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + codec.getEncoder(new DataOutputStream(baos)).write(original); + LogFile.Codec.Decoder decoder = + codec.getDecoder(new DataInputStream(new ByteArrayInputStream(baos.toByteArray()))); + + assertTrue(decoder.advance()); + Mutation decoded = decoder.current().getMutation(); + + for (String attrKey : ReplicationLogGroup.REPLICATION_ATTR_KEYS) { + byte[] actual = decoded.getAttribute(attrKey); + assertTrue("Attribute " + attrKey + " should not be present", actual == null); + } + } + + @Test + public void testPartialAttributesRoundTrip() throws IOException { + long ts = 12345L; + Put put = new Put(Bytes.toBytes("row")); + put.setTimestamp(ts); + put.addColumn(Bytes.toBytes("cf"), Bytes.toBytes("q"), ts, Bytes.toBytes("v")); + put.setAttribute(PhoenixIndexCodec.INDEX_UUID, HConstants.EMPTY_BYTE_ARRAY); + put.setAttribute(MutationState.MutationMetadataType.SCHEMA_NAME.toString(), Bytes.toBytes("S")); + + LogFile.Record original = + new LogFileRecord().setHBaseTableName("TBLPARTATTR").setCommitId(1L).setMutation(put); + + LogFileCodec codec = new LogFileCodec(); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + codec.getEncoder(new DataOutputStream(baos)).write(original); + LogFile.Codec.Decoder decoder = + codec.getDecoder(new DataInputStream(new ByteArrayInputStream(baos.toByteArray()))); + + assertTrue(decoder.advance()); + Mutation decoded = decoder.current().getMutation(); + + assertTrue( + "INDEX_UUID is not part of the replication envelope, so a UUID set on the mutation " + + "must not round-trip through the record", + decoded.getAttribute(PhoenixIndexCodec.INDEX_UUID) == null); + assertTrue("SCHEMA_NAME should match", Arrays.equals(Bytes.toBytes("S"), + decoded.getAttribute(MutationState.MutationMetadataType.SCHEMA_NAME.toString()))); + assertTrue("LOGICAL_TABLE_NAME should not be present", + decoded.getAttribute(MutationState.MutationMetadataType.LOGICAL_TABLE_NAME.toString()) + == null); + assertTrue("TENANT_ID should not be present", + decoded.getAttribute(MutationState.MutationMetadataType.TENANT_ID.toString()) == null); + } + } diff --git a/phoenix-core/src/test/java/org/apache/phoenix/replication/log/LogFileTestUtil.java b/phoenix-core/src/test/java/org/apache/phoenix/replication/log/LogFileTestUtil.java index a0a4e434422..cb2400325ac 100644 --- a/phoenix-core/src/test/java/org/apache/phoenix/replication/log/LogFileTestUtil.java +++ b/phoenix-core/src/test/java/org/apache/phoenix/replication/log/LogFileTestUtil.java @@ -30,7 +30,6 @@ import java.io.EOFException; import java.io.IOException; import java.io.OutputStream; -import java.util.ArrayList; import java.util.List; import org.apache.hadoop.fs.FSDataOutputStream; import org.apache.hadoop.fs.FSDataOutputStreamBuilder; @@ -44,6 +43,7 @@ import org.apache.hadoop.hbase.client.Mutation; import org.apache.hadoop.hbase.client.Put; import org.apache.hadoop.hbase.util.Bytes; +import org.apache.phoenix.replication.MutationCellGrouper; public interface LogFileTestUtil { @@ -51,11 +51,7 @@ public interface LogFileTestUtil { * Flatten a mutation's family cell map into the cell list that the writer ultimately receives. */ static List cellsOf(Mutation mutation) { - List cells = new ArrayList<>(); - for (List familyCells : mutation.getFamilyCellMap().values()) { - cells.addAll(familyCells); - } - return cells; + return MutationCellGrouper.flattenCells(mutation); } static LogFile.Record newPutRecord(String table, long commitId, String rowKey, long ts,