This is an automated email from the ASF dual-hosted git repository.
xiangfu0 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git
The following commit(s) were added to refs/heads/master by this push:
new b38d073146a Fix duplicate upsert rows after restart when a snapshot is
missed with metadataTTL (#19832)
b38d073146a is described below
commit b38d073146aefe1e72f2966608503a453bd6bf4e
Author: Akshay Upadhyay <[email protected]>
AuthorDate: Fri Oct 16 21:43:51 2026 +0530
Fix duplicate upsert rows after restart when a snapshot is missed with
metadataTTL (#19832)
---
.../UpsertSnapshotRestartIntegrationTest.java | 350 +++++++++++++++++++++
.../upsert/BasePartitionUpsertMetadataManager.java | 61 +++-
.../local/upsert/UpsertSnapshotRestartTest.java | 345 ++++++++++++++++++++
3 files changed, 753 insertions(+), 3 deletions(-)
diff --git
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/UpsertSnapshotRestartIntegrationTest.java
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/UpsertSnapshotRestartIntegrationTest.java
new file mode 100644
index 00000000000..8ae14427f3c
--- /dev/null
+++
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/UpsertSnapshotRestartIntegrationTest.java
@@ -0,0 +1,350 @@
+/**
+ * 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.pinot.integration.tests;
+
+import com.google.common.base.Joiner;
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.concurrent.locks.Lock;
+import javax.annotation.Nullable;
+import org.apache.commons.io.FileUtils;
+import org.apache.helix.model.IdealState;
+import org.apache.pinot.client.ResultSet;
+import org.apache.pinot.client.ResultSetGroup;
+import org.apache.pinot.common.utils.LLCSegmentName;
+import org.apache.pinot.core.data.manager.realtime.RealtimeTableDataManager;
+import org.apache.pinot.segment.local.data.manager.SegmentDataManager;
+import
org.apache.pinot.segment.local.indexsegment.immutable.ImmutableSegmentImpl;
+import
org.apache.pinot.segment.local.upsert.PartitionUpsertMetadataManager.SnapshotPass;
+import org.apache.pinot.segment.spi.V1Constants;
+import org.apache.pinot.spi.config.table.TableConfig;
+import org.apache.pinot.spi.config.table.TableType;
+import org.apache.pinot.spi.config.table.UpsertConfig;
+import org.apache.pinot.spi.data.Schema;
+import org.apache.pinot.spi.env.PinotConfiguration;
+import org.apache.pinot.spi.utils.CommonConstants.Server;
+import org.apache.pinot.spi.utils.Enablement;
+import org.apache.pinot.spi.utils.builder.TableNameBuilder;
+import org.apache.pinot.util.TestUtils;
+import org.roaringbitmap.buffer.MutableRoaringBitmap;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNotNull;
+
+
+/// Restarts the server of a full upsert table with metadata TTL, snapshot and
preload enabled, consuming from Kafka.
+/// Holding the segmentLock of the first segment while the next segment is
committed makes the snapshot pass skip it,
+/// like a segment reload holding the lock does, so its snapshot on disk is
stale or missing while it is out of TTL.
+/// The restarted server must still return exactly one row per primary key,
i.e. the segment must not be added as out
+/// of TTL with the stale snapshot, or with all docs valid when there is no
snapshot.
+public class UpsertSnapshotRestartIntegrationTest extends
BaseClusterIntegrationTestSet {
+ private static final String CSV_HEADER =
"playerId,name,game,score,timestampInEpoch,deleted";
+ private static final int NUM_KEYS = 20;
+ /// Must be smaller than the comparison-column gap between phase 1 (~1k) and
phase 2 (~200k) so seg0 is out of TTL
+ /// after phase 2.
+ private static final double TTL_MS = 100_000d;
+ private static final long PHASE1_BASE_TS = 1_000L;
+ private static final long PHASE2_BASE_TS = 200_000L;
+ private static final long WAIT_MS = 300_000L;
+
+ @BeforeClass
+ public void setUp()
+ throws Exception {
+ TestUtils.ensureDirectoriesExistAndEmpty(_tempDir, _segmentDir, _tarDir);
+ startZk();
+ startController();
+ startBroker();
+ startServers(1);
+ startKafkaWithoutTopic();
+ }
+
+ @AfterClass
+ public void tearDown()
+ throws Exception {
+ stopServer();
+ stopBroker();
+ stopController();
+ stopKafka();
+ stopZk();
+ FileUtils.deleteDirectory(_tempDir);
+ }
+
+ @Override
+ protected String getSchemaFileName() {
+ return "upsert_table_test.schema";
+ }
+
+ @Nullable
+ @Override
+ protected String getTimeColumnName() {
+ return "timestampInEpoch";
+ }
+
+ @Override
+ protected String getPartitionColumn() {
+ return "playerId";
+ }
+
+ @Override
+ protected int getNumKafkaPartitions() {
+ return 1;
+ }
+
+ @Override
+ protected int getRealtimeSegmentFlushSize() {
+ return NUM_KEYS;
+ }
+
+ @Override
+ protected void overrideServerConf(PinotConfiguration serverConf) {
+ serverConf.setProperty(Joiner.on(".")
+ .join(Server.INSTANCE_DATA_MANAGER_CONFIG_PREFIX,
Server.Upsert.CONFIG_PREFIX,
+ Server.Upsert.DEFAULT_ENABLE_SNAPSHOT), "true");
+ serverConf.setProperty(Joiner.on(".")
+ .join(Server.INSTANCE_DATA_MANAGER_CONFIG_PREFIX,
Server.Upsert.CONFIG_PREFIX,
+ Server.Upsert.DEFAULT_ENABLE_PRELOAD), "true");
+ }
+
+ @Test
+ public void testRestartWithoutLockContention()
+ throws Exception {
+ runScenario("noContention", false, false, false);
+ }
+
+ @Test
+ public void testRestartWithStaleSnapshot()
+ throws Exception {
+ runScenario("staleSnapshot", true, false, false);
+ }
+
+ @Test
+ public void testRestartWithMissingSnapshot()
+ throws Exception {
+ runScenario("missingSnapshot", true, true, false);
+ }
+
+ @Test
+ public void testReloadAfterRestart()
+ throws Exception {
+ runScenario("reload", true, false, true);
+ }
+
+ private List<String> rows(long baseTs) {
+ List<String> rows = new ArrayList<>();
+ for (int pk = 1; pk <= NUM_KEYS; pk++) {
+ rows.add(pk + ",name" + pk + ",game," + pk + "," + (baseTs + pk) +
",false");
+ }
+ return rows;
+ }
+
+ private RealtimeTableDataManager tdm(String realtimeTable) {
+ return (RealtimeTableDataManager)
_serverStarters.get(0).getServerInstance().getInstanceDataManager()
+ .getTableDataManager(realtimeTable);
+ }
+
+ private List<String> sortedLlcSegmentNames(String realtimeTable) {
+ IdealState idealState =
_helixResourceManager.getTableIdealState(realtimeTable);
+ if (idealState == null) {
+ return List.of();
+ }
+ List<String> names = new ArrayList<>();
+ for (String segmentName : idealState.getRecord().getMapFields().keySet()) {
+ if (LLCSegmentName.isLLCSegment(segmentName)) {
+ names.add(segmentName);
+ }
+ }
+ names.sort(Comparator.comparingInt(name ->
LLCSegmentName.of(name).getSequenceNumber()));
+ return names;
+ }
+
+ private File segmentDir(String realtimeTable, TableConfig tableConfig,
String segmentName) {
+ return tdm(realtimeTable).getSegmentDataDir(segmentName, null,
tableConfig);
+ }
+
+ private static File findSnapshot(File segmentDir) {
+ File v3 = new File(segmentDir, "v3");
+ File snap = new File(v3, V1Constants.VALID_DOC_IDS_SNAPSHOT_FILE_NAME);
+ if (snap.exists()) {
+ return snap;
+ }
+ File[] found = new File[1];
+ FileUtils.listFiles(segmentDir, null, true).forEach(f -> {
+ if (f.getName().equals(V1Constants.VALID_DOC_IDS_SNAPSHOT_FILE_NAME)) {
+ found[0] = f;
+ }
+ });
+ return found[0];
+ }
+
+ private long countStar(String table)
+ throws Exception {
+ return getPinotConnection().execute("SELECT COUNT(*) FROM " +
table).getResultSet(0).getLong(0, 0);
+ }
+
+ private int duplicatePkCount(String table)
+ throws Exception {
+ ResultSet dups = getPinotConnection().execute(
+ "SELECT playerId, COUNT(*) FROM " + table + " GROUP BY playerId HAVING
COUNT(*) > 1 LIMIT 100")
+ .getResultSet(0);
+ return dups.getRowCount();
+ }
+
+ /// Returns the last finished snapshot pass of the only partition. A
snapshot pass runs when a consuming segment
+ /// starts, except the first one, which starts before any record is consumed.
+ @Nullable
+ private SnapshotPass lastSnapshotPass(String realtimeTable) {
+ return
tdm(realtimeTable).getTableUpsertMetadataManager().getOrCreatePartitionManager(0).getLastSnapshotPass();
+ }
+
+ /// Waits for a snapshot pass to finish after the given one, or for the
first one if null.
+ private SnapshotPass waitForNextSnapshotPass(String realtimeTable, @Nullable
SnapshotPass previousPass) {
+ TestUtils.waitForCondition(aVoid -> {
+ SnapshotPass pass = lastSnapshotPass(realtimeTable);
+ return pass != null && pass != previousPass;
+ }, 500L, WAIT_MS, "expected a snapshot pass to finish for " +
realtimeTable);
+ return lastSnapshotPass(realtimeTable);
+ }
+
+ /// Returns whether the validDocIds snapshot of the segment on disk matches
its validDocIds in memory.
+ private boolean isSnapshotUpToDate(String realtimeTable, String segmentName)
{
+ RealtimeTableDataManager tableDataManager = tdm(realtimeTable);
+ SegmentDataManager segmentDataManager =
tableDataManager.acquireSegment(segmentName);
+ assertNotNull(segmentDataManager, "segment " + segmentName + " not found");
+ try {
+ ImmutableSegmentImpl segment = (ImmutableSegmentImpl)
segmentDataManager.getSegment();
+ MutableRoaringBitmap snapshot =
segment.loadDocIdsFromSnapshot(V1Constants.VALID_DOC_IDS_SNAPSHOT_FILE_NAME);
+ return snapshot != null &&
snapshot.equals(segment.getValidDocIds().getMutableRoaringBitmap());
+ } finally {
+ tableDataManager.releaseSegment(segmentDataManager);
+ }
+ }
+
+ private String summary(String table)
+ throws Exception {
+ return "count(*)=" + countStar(table) + ", pks with >1 rows=" +
duplicatePkCount(table);
+ }
+
+ private void waitForRowCount(String table, long expected)
+ throws Exception {
+ TestUtils.waitForCondition(aVoid -> {
+ try {
+ return countStar(table) == expected;
+ } catch (Exception e) {
+ return false;
+ }
+ }, 500L, WAIT_MS, "expected " + expected + " rows in " + table);
+ }
+
+ private void waitForLlcSegments(String realtimeTable, int minSegments)
+ throws Exception {
+ TestUtils.waitForCondition(aVoid ->
sortedLlcSegmentNames(realtimeTable).size() >= minSegments, 500L, WAIT_MS,
+ "expected >=" + minSegments + " LLC segments in ideal state, have "
+ + sortedLlcSegmentNames(realtimeTable).size());
+ }
+
+ private void runScenario(String name, boolean holdLock, boolean
deleteSnapshotFile, boolean reloadAfterRestart)
+ throws Exception {
+ String table = "upsertSnapshotRestart_" + name;
+ String rtTable = TableNameBuilder.REALTIME.tableNameWithType(table);
+ String topic = table + "_topic";
+ createKafkaTopic(topic);
+ Schema schema = createSchema();
+ schema.setSchemaName(table);
+ addSchema(schema);
+ UpsertConfig upsertConfig = new UpsertConfig(UpsertConfig.Mode.FULL);
+ upsertConfig.setSnapshot(Enablement.ENABLE);
+ upsertConfig.setEnablePreload(true);
+ upsertConfig.setMetadataTTL(TTL_MS);
+ TableConfig tableConfig = createCSVUpsertTableConfig(table, topic,
getNumKafkaPartitions(),
+ getCSVDecoderProperties(",", CSV_HEADER), upsertConfig, "playerId");
+ addTableConfig(tableConfig);
+
+ pushCsvIntoKafka(rows(PHASE1_BASE_TS), topic, 0);
+ waitForRowCount(table, NUM_KEYS);
+ waitForLlcSegments(rtTable, 2);
+ String seg0Name = sortedLlcSegmentNames(rtTable).get(0);
+ // The first snapshot pass runs when the second consuming segment starts
+ SnapshotPass firstPass = waitForNextSnapshotPass(rtTable, null);
+
+ Lock lock = holdLock ? tdm(rtTable).getSegmentLock(seg0Name) : null;
+ if (lock != null) {
+ lock.lock();
+ }
+ try {
+ pushCsvIntoKafka(rows(PHASE2_BASE_TS), topic, 0);
+ // The next snapshot pass runs when the third consuming segment starts,
i.e. after the rows of phase 2 replaced
+ // all the rows of seg0. Hold the lock until the pass finishes, so that
it skips seg0.
+ waitForNextSnapshotPass(rtTable, firstPass);
+ } finally {
+ if (lock != null) {
+ lock.unlock();
+ }
+ }
+ if (deleteSnapshotFile) {
+ File snapshot = findSnapshot(segmentDir(rtTable, tableConfig, seg0Name));
+ if (snapshot != null) {
+ FileUtils.forceDelete(snapshot);
+ }
+ }
+ // Make sure the restart below exercises a stale or missing snapshot of
seg0 when holding the lock, and an
+ // up-to-date one otherwise
+ assertEquals(isSnapshotUpToDate(rtTable, seg0Name), !holdLock,
+ name + ": unexpected state of the validDocIds snapshot of " + seg0Name
+ " before restart");
+ assertEquals(countStar(table), (long) NUM_KEYS, "pre-restart count");
+
+ _serverStarters.set(0, restartServer(_serverStarters.get(0)));
+ // A segment is routed to only after its state transition, which adds it
to the upsert metadata, so the result is
+ // final as soon as all the segments are queried without exception
+ TestUtils.waitForCondition(aVoid -> {
+ try {
+ ResultSetGroup resultSetGroup = getPinotConnection().execute("SELECT
COUNT(*) FROM " + table);
+ return resultSetGroup.getExceptions().isEmpty() &&
resultSetGroup.getExecutionStats().getNumSegmentsQueried()
+ >= 3;
+ } catch (Exception e) {
+ return false;
+ }
+ }, 1000L, WAIT_MS, "segments back online after restart");
+ String afterRestart = summary(table);
+
+ assertEquals(countStar(table), (long) NUM_KEYS,
+ name + ": expected one row per primary key after restart, got " +
afterRestart);
+ assertEquals(duplicatePkCount(table), 0,
+ name + ": found duplicate primary keys after restart, got " +
afterRestart);
+
+ if (reloadAfterRestart) {
+ String jobId = reloadTableAndValidateResponse(table, TableType.REALTIME,
false);
+ TestUtils.waitForCondition(aVoid -> {
+ try {
+ return isReloadJobCompleted(jobId);
+ } catch (Exception e) {
+ return false;
+ }
+ }, 1000L, WAIT_MS, "reload job finished");
+ String afterReload = summary(table);
+ assertEquals(countStar(table), (long) NUM_KEYS, "reload should keep one
row per PK, got " + afterReload);
+ assertEquals(duplicatePkCount(table), 0, "duplicate PKs after reload,
got " + afterReload);
+ }
+ dropRealtimeTable(table);
+ }
+}
diff --git
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/upsert/BasePartitionUpsertMetadataManager.java
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/upsert/BasePartitionUpsertMetadataManager.java
index 3a2c00aeab5..894925faa48 100644
---
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/upsert/BasePartitionUpsertMetadataManager.java
+++
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/upsert/BasePartitionUpsertMetadataManager.java
@@ -131,6 +131,9 @@ public abstract class BasePartitionUpsertMetadataManager
implements PartitionUps
// Used to maintain the largestSeenComparisonValue to avoid handling
out-of-ttl segments/records.
// If upsertTTL enabled, we will keep track of largestSeenComparisonValue to
compute expired segments.
protected final AtomicDouble _largestSeenComparisonValue;
+ // The TTL watermark last persisted to disk. A segment out of TTL against it
has an up-to-date snapshot on disk, see
+ // getWatermarkToPersist().
+ private volatile double _persistedWatermark;
// The following variables are always accessed within synchronized block
private boolean _stopped;
@@ -202,6 +205,7 @@ public abstract class BasePartitionUpsertMetadataManager
implements PartitionUps
_largestSeenComparisonValue = new AtomicDouble(TTL_WATERMARK_NOT_SET);
WatermarkUtils.deleteWatermark(getWatermarkFile());
}
+ _persistedWatermark = _largestSeenComparisonValue.get();
}
@Override
@@ -346,6 +350,14 @@ public abstract class BasePartitionUpsertMetadataManager
implements PartitionUps
&& maxComparisonValue < _largestSeenComparisonValue.get() -
_metadataTTL;
}
+ /// Returns whether the segment can be handled as out of TTL when it's added
or preloaded, i.e. with its validDocIds
+ /// snapshot taken as is. Besides the watermark in memory, the segment must
also be out of TTL against the persisted
+ /// watermark, which is capped to keep any segment with a stale or missing
snapshot within TTL. The watermark in
+ /// memory alone is not enough, e.g. on server restart it's bumped by the
newer segments loaded earlier.
+ protected boolean canSkipSegmentOutOfTTL(double maxComparisonValue) {
+ return isOutOfMetadataTTL(maxComparisonValue) && maxComparisonValue <
_persistedWatermark - _metadataTTL;
+ }
+
protected boolean isOutOfMetadataTTL(IndexSegment segment) {
if (_metadataTTL > 0) {
double maxComparisonValue = getMaxComparisonValue(segment);
@@ -390,7 +402,7 @@ public abstract class BasePartitionUpsertMetadataManager
implements PartitionUps
// watermark stays unchanged - the segment's rows are not in the map, so
recording their max would misrepresent
// what we have observed and let the sweep expire other keys against a
phantom watermark.
double maxComparisonValue = isTTLEnabled() ?
getMaxComparisonValue(segment) : TTL_WATERMARK_NOT_SET;
- if (isTTLEnabled() && isOutOfMetadataTTL(maxComparisonValue) &&
skipAddSegmentOutOfTTL(segment)) {
+ if (isTTLEnabled() && canSkipSegmentOutOfTTL(maxComparisonValue) &&
skipAddSegmentOutOfTTL(segment)) {
updateLargestSeenComparisonValue(maxComparisonValue);
return;
}
@@ -474,7 +486,7 @@ public abstract class BasePartitionUpsertMetadataManager
implements PartitionUps
}
// Bump watermark after rows are added; see doAddSegment.
double maxComparisonValue = isTTLEnabled() ?
getMaxComparisonValue(segment) : TTL_WATERMARK_NOT_SET;
- if (isTTLEnabled() && isOutOfMetadataTTL(maxComparisonValue)
+ if (isTTLEnabled() && canSkipSegmentOutOfTTL(maxComparisonValue)
&& skipPreloadSegmentOutOfTTL(segment, validDocIds)) {
updateLargestSeenComparisonValue(maxComparisonValue);
return;
@@ -1167,7 +1179,9 @@ public abstract class BasePartitionUpsertMetadataManager
implements PartitionUps
// updated validDocIds bitmaps. If the TTL watermark is persisted first,
segments out of TTL may get loaded with
// stale bitmaps or even no bitmap snapshots to use.
if (isTTLEnabled()) {
- WatermarkUtils.persistWatermark(_largestSeenComparisonValue.get(),
getWatermarkFile());
+ double watermark = getWatermarkToPersist();
+ WatermarkUtils.persistWatermark(watermark, getWatermarkFile());
+ _persistedWatermark = watermark;
}
updateSnapshotMetrics(numImmutableSegments, numPrimaryKeysInSnapshot,
numQueryableDocIdsInSnapshot,
numTrackedSegments, numConsumingSegments, numUnchangedSegments);
@@ -1177,6 +1191,38 @@ public abstract class BasePartitionUpsertMetadataManager
implements PartitionUps
numConsumingSegments, System.currentTimeMillis() - startTimeMs);
}
+ /// Returns the TTL watermark to persist after taking snapshots. A segment
still tracked as updated after the snapshot
+ /// pass, e.g. skipped on segmentLock contention or added while the pass
ran, has a stale snapshot or no snapshot on
+ /// disk. If such segment were out of TTL after a restart, it would be added
with the stale snapshot, or with all
+ /// docs treated as valid when there is no snapshot, which shows up as
duplicate rows on this replica only. So cap
+ /// the persisted watermark to keep such segments within TTL, and get them
added with the regular flow after restart.
+ /// The watermark in memory is not changed, and primary keys are still
expired against it, so a segment that keeps
+ /// missing its snapshot doesn't keep the expired primary keys in memory.
+ private double getWatermarkToPersist() {
+ double watermark = _largestSeenComparisonValue.get();
+ if (_metadataTTL <= 0) {
+ return watermark;
+ }
+ double cappedWatermark = watermark;
+ // Unlike taking the snapshots above, reading the max comparison value
doesn't need the segmentLock or the check
+ // against _trackedSegments: it comes from the segment metadata, which can
still be read after the segment is
+ // destroyed, and a segment removed in the meantime can only cap the
watermark lower than needed.
+ for (ImmutableSegmentImpl segment : _updatedSegmentsSinceLastSnapshot) {
+ double maxComparisonValue = getMaxComparisonValue(segment);
+ double maxWatermark = maxComparisonValue + _metadataTTL;
+ // Make sure the segment is not out of TTL as checked by
isOutOfMetadataTTL(), regardless of rounding
+ while (maxComparisonValue < maxWatermark - _metadataTTL) {
+ maxWatermark = Math.nextDown(maxWatermark);
+ }
+ cappedWatermark = Math.min(cappedWatermark, maxWatermark);
+ }
+ if (cappedWatermark < watermark) {
+ _logger.warn("Capped TTL watermark to persist from: {} to: {} because
some segments missed taking snapshot",
+ watermark, cappedWatermark);
+ }
+ return cappedWatermark;
+ }
+
private void updateSnapshotMetrics(int numImmutableSegments, long
numPrimaryKeysInSnapshot,
long numQueryableDocIdsInSnapshot, int numTrackedSegments, int
numConsumingSegments, int numUnchangedSegments) {
_serverMetrics.setValueOfPartitionGauge(_tableNameWithType, _partitionId,
@@ -1214,14 +1260,23 @@ public abstract class
BasePartitionUpsertMetadataManager implements PartitionUps
return new File(_tableIndexDir, V1Constants.TTL_WATERMARK_TABLE_PARTITION
+ _partitionId);
}
+ /// Returns the TTL watermark last persisted to disk, as computed by
getWatermarkToPersist(). The validDocIds
+ /// snapshots on disk are up to date for all the segments out of TTL against
it, while they can be stale for the
+ /// segments out of TTL only against the watermark in memory.
+ protected double getPersistedWatermark() {
+ return _persistedWatermark;
+ }
+
@VisibleForTesting
double getWatermark() {
return _largestSeenComparisonValue.get();
}
+ /// Sets the watermark as if it was loaded from the persisted watermark file.
@VisibleForTesting
void setWatermark(double watermark) {
_largestSeenComparisonValue.set(watermark);
+ _persistedWatermark = watermark;
}
@Override
diff --git
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/upsert/UpsertSnapshotRestartTest.java
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/upsert/UpsertSnapshotRestartTest.java
new file mode 100644
index 00000000000..dc1723d5f58
--- /dev/null
+++
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/upsert/UpsertSnapshotRestartTest.java
@@ -0,0 +1,345 @@
+/**
+ * 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.pinot.segment.local.upsert;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.locks.ReentrantLock;
+import org.apache.commons.io.FileUtils;
+import org.apache.pinot.common.metrics.ServerMetrics;
+import org.apache.pinot.segment.local.data.manager.TableDataManager;
+import
org.apache.pinot.segment.local.indexsegment.immutable.ImmutableSegmentImpl;
+import
org.apache.pinot.segment.local.indexsegment.immutable.ImmutableSegmentLoader;
+import
org.apache.pinot.segment.local.segment.creator.impl.SegmentIndexCreationDriverImpl;
+import org.apache.pinot.segment.local.segment.readers.GenericRowRecordReader;
+import org.apache.pinot.segment.local.utils.WatermarkUtils;
+import org.apache.pinot.segment.spi.MutableSegment;
+import org.apache.pinot.segment.spi.V1Constants;
+import org.apache.pinot.segment.spi.creator.SegmentGeneratorConfig;
+import
org.apache.pinot.segment.spi.index.mutable.ThreadSafeMutableRoaringBitmap;
+import org.apache.pinot.spi.config.table.TableConfig;
+import org.apache.pinot.spi.config.table.TableType;
+import org.apache.pinot.spi.data.FieldSpec;
+import org.apache.pinot.spi.data.Schema;
+import org.apache.pinot.spi.data.readers.GenericRow;
+import org.apache.pinot.spi.data.readers.PrimaryKey;
+import org.apache.pinot.spi.utils.ReadMode;
+import org.apache.pinot.spi.utils.builder.TableConfigBuilder;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertTrue;
+
+
+/// Restarts a full upsert partition with metadata TTL and snapshots enabled,
using real segments, snapshot files and
+/// TTL watermark file. Only the [TableDataManager] is mocked, to make
segmentLock contended for chosen segments.
+///
+/// segA (ts=100) has pk 1, 2, 3 and the newer segB (ts=290) has pk 1, 2, so
the expected valid docs are segA={2} and
+/// segB={0,1}. With TTL=30, segA is out of TTL against the largest seen
comparison value 290. When the snapshot pass
+/// misses segA, its snapshot on disk is stale or missing, and segA must not
be treated as out of TTL after a restart,
+/// otherwise it is added with the stale snapshot or with all docs valid, i.e.
with duplicate primary keys.
+public class UpsertSnapshotRestartTest {
+ private static final File TEMP_DIR = new File(FileUtils.getTempDirectory(),
"UpsertSnapshotRestartTest");
+ private static final File SEGMENTS_DIR = new File(TEMP_DIR, "segments");
+ private static final String RAW_TABLE_NAME = "testTable";
+ private static final String REALTIME_TABLE_NAME = "testTable_REALTIME";
+ private static final Schema SCHEMA = new
Schema.SchemaBuilder().setSchemaName(RAW_TABLE_NAME)
+ .addSingleValueDimension("pk", FieldSpec.DataType.INT).addMetric("ts",
FieldSpec.DataType.INT)
+ .setPrimaryKeyColumns(List.of("pk")).build();
+ private static final TableConfig TABLE_CONFIG =
+ new
TableConfigBuilder(TableType.REALTIME).setTableName(RAW_TABLE_NAME).build();
+ private static final double METADATA_TTL = 30;
+
+ private final Set<String> _contendedSegments = ConcurrentHashMap.newKeySet();
+ private TableDataManager _tableDataManager;
+
+ @BeforeMethod
+ public void setUp()
+ throws Exception {
+ FileUtils.deleteQuietly(TEMP_DIR);
+ FileUtils.forceMkdir(TEMP_DIR);
+ ServerMetrics.register(mock(ServerMetrics.class));
+ _contendedSegments.clear();
+ _tableDataManager = mock(TableDataManager.class);
+ when(_tableDataManager.getTableDataDir()).thenReturn(TEMP_DIR);
+ ConcurrentHashMap<String, ReentrantLock> segmentLocks = new
ConcurrentHashMap<>();
+ when(_tableDataManager.getSegmentLock(anyString())).thenAnswer(
+ invocation -> segmentLocks.computeIfAbsent(invocation.getArgument(0),
this::newSegmentLock));
+ buildSegment("segA", new int[]{1, 2, 3}, 100);
+ buildSegment("segB", new int[]{1, 2}, 290);
+ }
+
+ @AfterMethod
+ public void tearDown() {
+ FileUtils.deleteQuietly(TEMP_DIR);
+ }
+
+ @DataProvider
+ public static Object[][] restartModes() {
+ // Whether to preload the segments with snapshot on restart, and whether
to add segB before segA
+ return new Object[][]{{false, false}, {false, true}, {true, false}, {true,
true}};
+ }
+
+ @Test(dataProvider = "restartModes")
+ public void testRestartWithoutMissedSnapshot(boolean preload, boolean
addSegBFirst)
+ throws Exception {
+ ConcurrentMapPartitionUpsertMetadataManager manager = newManager();
+ ImmutableSegmentImpl segA = addSegment(manager, "segA");
+ ImmutableSegmentImpl segB = addSegment(manager, "segB");
+ manager.takeSnapshot();
+ assertEquals(persistedWatermark(manager), 290.0);
+ close(manager, segA, segB);
+
+ assertEquals(restart(preload, addSegBFirst), "{2} | {0,1}");
+ }
+
+ /// segA never got a snapshot, e.g. its segmentLock was held by a reload
whenever the snapshot was taken.
+ @Test(dataProvider = "restartModes")
+ public void testRestartWithMissingSnapshot(boolean preload, boolean
addSegBFirst)
+ throws Exception {
+ ConcurrentMapPartitionUpsertMetadataManager manager = newManager();
+ ImmutableSegmentImpl segA = addSegment(manager, "segA");
+ ImmutableSegmentImpl segB = addSegment(manager, "segB");
+ _contendedSegments.add("segA");
+ manager.takeSnapshot();
+ assertFalse(hasSnapshot("segA"));
+ assertTrue(hasSnapshot("segB"));
+ // Capped to keep segA within TTL: 100 >= 130 - 30
+ assertEquals(persistedWatermark(manager), 130.0);
+ close(manager, segA, segB);
+
+ assertEquals(restart(preload, addSegBFirst), "{2} | {0,1}");
+ }
+
+ /// segA got a snapshot while all its docs were valid, then missed the
snapshot after segB invalidated 2 of them.
+ @Test(dataProvider = "restartModes")
+ public void testRestartWithStaleSnapshot(boolean preload, boolean
addSegBFirst)
+ throws Exception {
+ ConcurrentMapPartitionUpsertMetadataManager manager = newManager();
+ ImmutableSegmentImpl segA = addSegment(manager, "segA");
+ manager.takeSnapshot();
+ assertEquals(loadSnapshot("segA"), "{0,1,2}");
+ ImmutableSegmentImpl segB = addSegment(manager, "segB");
+ _contendedSegments.add("segA");
+ manager.takeSnapshot();
+ assertEquals(loadSnapshot("segA"), "{0,1,2}");
+ // segB is not snapshotted either as the loop for segments without
snapshot is skipped
+ assertFalse(hasSnapshot("segB"));
+ assertEquals(persistedWatermark(manager), 130.0);
+ close(manager, segA, segB);
+
+ assertEquals(restart(preload, addSegBFirst), "{2} | {0,1}");
+ }
+
+ @Test
+ public void testWatermarkAdvancesOnceMissedSnapshotIsTaken()
+ throws Exception {
+ ConcurrentMapPartitionUpsertMetadataManager manager = newManager();
+ ImmutableSegmentImpl segA = addSegment(manager, "segA");
+ ImmutableSegmentImpl segB = addSegment(manager, "segB");
+ _contendedSegments.add("segA");
+ manager.takeSnapshot();
+ assertEquals(persistedWatermark(manager), 130.0);
+ // The watermark in memory is not capped, so the expired primary keys are
still removed as before
+ assertEquals(manager.getWatermark(), 290.0);
+
+ _contendedSegments.clear();
+ manager.takeSnapshot();
+ assertEquals(loadSnapshot("segA"), "{2}");
+ assertEquals(persistedWatermark(manager), 290.0);
+ close(manager, segA, segB);
+ }
+
+ /// The just committed segment can miss its snapshot because its segmentLock
is still held by the thread replacing the
+ /// consuming segment. It is within TTL, so it should not hold back the
persisted watermark.
+ @Test
+ public void testMissedSnapshotWithinTtlDoesNotCapWatermark()
+ throws Exception {
+ ConcurrentMapPartitionUpsertMetadataManager manager = newManager();
+ ImmutableSegmentImpl segA = addSegment(manager, "segA");
+ ImmutableSegmentImpl segB = addSegment(manager, "segB");
+ _contendedSegments.add("segB");
+ manager.takeSnapshot();
+ assertTrue(hasSnapshot("segA"));
+ assertFalse(hasSnapshot("segB"));
+ assertEquals(persistedWatermark(manager), 290.0);
+ close(manager, segA, segB);
+
+ for (Object[] restartMode : restartModes()) {
+ assertEquals(restart((boolean) restartMode[0], (boolean)
restartMode[1]), "{2} | {0,1}");
+ }
+ }
+
+ /// Stopping the metadata manager does not take a snapshot. Rows consumed
into the consuming segment are not
+ /// committed, and are consumed again after a restart, so the snapshots must
not include the doc invalidated by them.
+ /// Otherwise pk 1 would be invisible after a restart until the consuming
segment catches up again.
+ @Test
+ public void testStopDoesNotSnapshotUncommittedUpdates()
+ throws Exception {
+ ConcurrentMapPartitionUpsertMetadataManager manager = newManager();
+ ImmutableSegmentImpl segA = addSegment(manager, "segA");
+ manager.takeSnapshot();
+ MutableSegment consumingSegment = mock(MutableSegment.class);
+ when(consumingSegment.getSegmentName()).thenReturn("segC");
+ when(consumingSegment.getValidDocIds()).thenReturn(new
ThreadSafeMutableRoaringBitmap());
+ manager.addRecord(consumingSegment, new RecordInfo(new PrimaryKey(new
Object[]{1}), 0, 200, false));
+ assertEquals(segA.getValidDocIds().getMutableRoaringBitmap().toString(),
"{1,2}");
+ manager.stop();
+ manager.close();
+ segA.destroy();
+ assertEquals(loadSnapshot("segA"), "{0,1,2}");
+
+ manager = newManager();
+ segA = load("segA");
+ manager.preloadSegment(segA);
+ assertEquals(segA.getValidDocIds().getMutableRoaringBitmap().toString(),
"{0,1,2}");
+ manager.stop();
+ manager.close();
+ segA.destroy();
+ }
+
+ private ReentrantLock newSegmentLock(String segmentName) {
+ return new ReentrantLock() {
+ @Override
+ public boolean tryLock() {
+ return !_contendedSegments.contains(segmentName) && super.tryLock();
+ }
+ };
+ }
+
+ private ConcurrentMapPartitionUpsertMetadataManager newManager() {
+ UpsertContext context = new
UpsertContext.Builder().setTableConfig(TABLE_CONFIG).setSchema(SCHEMA)
+
.setTableDataManager(_tableDataManager).setPrimaryKeyColumns(List.of("pk"))
+
.setComparisonColumns(List.of("ts")).setEnableSnapshot(true).setMetadataTTL(METADATA_TTL)
+ .setTableIndexDir(TEMP_DIR).build();
+ ConcurrentMapPartitionUpsertMetadataManager manager =
+ new ConcurrentMapPartitionUpsertMetadataManager(REALTIME_TABLE_NAME,
0, context);
+ manager._gotFirstConsumingSegment = true;
+ return manager;
+ }
+
+ private static void buildSegment(String segmentName, int[] primaryKeys, int
timestamp)
+ throws Exception {
+ List<GenericRow> rows = new ArrayList<>();
+ for (int primaryKey : primaryKeys) {
+ GenericRow row = new GenericRow();
+ row.putValue("pk", primaryKey);
+ row.putValue("ts", timestamp);
+ rows.add(row);
+ }
+ SegmentGeneratorConfig config = new SegmentGeneratorConfig(TABLE_CONFIG,
SCHEMA);
+ config.setOutDir(SEGMENTS_DIR.getAbsolutePath());
+ config.setSegmentName(segmentName);
+ config.setTableName(RAW_TABLE_NAME);
+ SegmentIndexCreationDriverImpl driver = new
SegmentIndexCreationDriverImpl();
+ driver.init(config, new GenericRowRecordReader(rows));
+ driver.build();
+ }
+
+ private static ImmutableSegmentImpl load(String segmentName)
+ throws Exception {
+ return (ImmutableSegmentImpl) ImmutableSegmentLoader.load(new
File(SEGMENTS_DIR, segmentName), ReadMode.mmap);
+ }
+
+ private static ImmutableSegmentImpl
addSegment(ConcurrentMapPartitionUpsertMetadataManager manager,
+ String segmentName)
+ throws Exception {
+ ImmutableSegmentImpl segment = load(segmentName);
+ manager.addSegment(segment);
+ return segment;
+ }
+
+ private static boolean hasSnapshot(String segmentName)
+ throws Exception {
+ ImmutableSegmentImpl segment = load(segmentName);
+ try {
+ return
segment.loadDocIdsFromSnapshot(V1Constants.VALID_DOC_IDS_SNAPSHOT_FILE_NAME) !=
null;
+ } finally {
+ segment.destroy();
+ }
+ }
+
+ private static String loadSnapshot(String segmentName)
+ throws Exception {
+ ImmutableSegmentImpl segment = load(segmentName);
+ try {
+ return
String.valueOf(segment.loadDocIdsFromSnapshot(V1Constants.VALID_DOC_IDS_SNAPSHOT_FILE_NAME));
+ } finally {
+ segment.destroy();
+ }
+ }
+
+ private static double
persistedWatermark(ConcurrentMapPartitionUpsertMetadataManager manager) {
+ double watermark =
WatermarkUtils.loadWatermark(manager.getWatermarkFile(),
BasePartitionUpsertMetadataManager
+ .TTL_WATERMARK_NOT_SET);
+ assertEquals(manager.getPersistedWatermark(), watermark, "persisted
watermark in memory and on disk differ");
+ return watermark;
+ }
+
+ private static void close(ConcurrentMapPartitionUpsertMetadataManager
manager, ImmutableSegmentImpl... segments)
+ throws Exception {
+ manager.stop();
+ manager.close();
+ for (ImmutableSegmentImpl segment : segments) {
+ segment.destroy();
+ }
+ }
+
+ /// Loads the segments into a new metadata manager as a server restart does:
preloads the segments with snapshot
+ /// first if preload is enabled, then adds the remaining segments. Returns
the valid docs as "segA | segB", where
+ /// "ALL" means no validDocIds, i.e. all docs are returned by queries.
+ private String restart(boolean preload, boolean addSegBFirst)
+ throws Exception {
+ ConcurrentMapPartitionUpsertMetadataManager manager = newManager();
+ // The persisted watermark is loaded from the watermark file on restart
+ persistedWatermark(manager);
+ ImmutableSegmentImpl segA = load("segA");
+ ImmutableSegmentImpl segB = load("segB");
+ List<ImmutableSegmentImpl> segments = addSegBFirst ? List.of(segB, segA) :
List.of(segA, segB);
+ List<ImmutableSegmentImpl> segmentsToAdd = new ArrayList<>();
+ for (ImmutableSegmentImpl segment : segments) {
+ if (preload &&
segment.loadDocIdsFromSnapshot(V1Constants.VALID_DOC_IDS_SNAPSHOT_FILE_NAME) !=
null) {
+ manager.preloadSegment(segment);
+ } else {
+ segmentsToAdd.add(segment);
+ }
+ }
+ for (ImmutableSegmentImpl segment : segmentsToAdd) {
+ manager.addSegment(segment);
+ }
+ String validDocIds = validDocIds(segA) + " | " + validDocIds(segB);
+ close(manager, segA, segB);
+ return validDocIds;
+ }
+
+ private static String validDocIds(ImmutableSegmentImpl segment) {
+ ThreadSafeMutableRoaringBitmap validDocIds = segment.getValidDocIds();
+ return validDocIds != null ?
validDocIds.getMutableRoaringBitmap().toString() : "ALL";
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]