This is an automated email from the ASF dual-hosted git repository. Caideyipi pushed a commit to branch fix/subscription-wal-backed-inflight in repository https://gitbox.apache.org/repos/asf/iotdb.git
commit d1c70f5ba78dc2d5d8b6fbc8b392d76de7f90841 Author: Caideyipi <[email protected]> AuthorDate: Wed Oct 14 12:40:36 2026 +0800 [Subscription] Replay uncommitted delivery payloads from WAL --- .../apache/iotdb/db/i18n/DataNodePipeMessages.java | 2 + .../apache/iotdb/db/i18n/DataNodePipeMessages.java | 2 + .../consensus/ConsensusLogToTabletConverter.java | 28 + .../consensus/ConsensusPrefetchingQueue.java | 393 ++++++++++++- .../db/subscription/event/SubscriptionEvent.java | 70 ++- .../cache/CachedSubscriptionPollResponse.java | 1 + .../response/SubscriptionEventSingleResponse.java | 15 +- .../receiver/SubscriptionReceiverV1.java | 7 +- .../ConsensusLogToTabletConverterTest.java | 25 + .../ConsensusPrefetchingQueueDeliveryTest.java | 627 +++++++++++++++++++++ 10 files changed, 1157 insertions(+), 13 deletions(-) diff --git a/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodePipeMessages.java b/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodePipeMessages.java index 836ac88d84f..bec917531be 100644 --- a/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodePipeMessages.java +++ b/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodePipeMessages.java @@ -2704,4 +2704,6 @@ public final class DataNodePipeMessages { "Failed to read OPC UA server operation limits, use defaults: maxNodesPerWrite={}, maxNodesPerNodeManagement={}"; public static final String MESSAGE_ARG_SUBSCRIPTION_ENTRY_REQUIRES_ARG_BYTES_ABOVE_THE_CURRENT_PER_QUEUE_MAXIMUM_ARG_BYTES_DATANODE_BUDGET_ARG_BYTES_REDUCE_THE_WRITE_BATCH_FIELD_SIZE_OR_INCREASE_SUBSCRIPTION_MATERIALIZATION_MEMORY_WAL_PROGRESS_HAS_NOT_ADVANCED_AFCBC7FC = "[%s] Subscription entry requires %d bytes, above the current per-queue maximum %d bytes (DataNode budget %d bytes). Reduce the write batch/field size or increase subscription materialization memory. WAL progress has not advanced."; + public static final String LOG_SUBSCRIPTION_FAILED_TO_REPLAY_DELIVERY_BATCH_ARG_FROM_WAL_RETAINING_UNCOMMITTED_STATE_0654EACD = + "Subscription failed to replay delivery batch {} from WAL; retaining uncommitted state"; } diff --git a/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodePipeMessages.java b/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodePipeMessages.java index 5840da12daf..5b300e79b4b 100644 --- a/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodePipeMessages.java +++ b/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodePipeMessages.java @@ -2529,4 +2529,6 @@ public final class DataNodePipeMessages { "读取 OPC UA 服务器操作限制失败,使用默认值:maxNodesPerWrite={},maxNodesPerNodeManagement={}"; public static final String MESSAGE_ARG_SUBSCRIPTION_ENTRY_REQUIRES_ARG_BYTES_ABOVE_THE_CURRENT_PER_QUEUE_MAXIMUM_ARG_BYTES_DATANODE_BUDGET_ARG_BYTES_REDUCE_THE_WRITE_BATCH_FIELD_SIZE_OR_INCREASE_SUBSCRIPTION_MATERIALIZATION_MEMORY_WAL_PROGRESS_HAS_NOT_ADVANCED_AFCBC7FC = "[%s] 订阅 entry 需要 %d 字节,超过当前队列可用上限 %d 字节(DataNode 预算 %d 字节)。请减小写入 batch/字段大小或增加订阅物化内存。WAL 进度未推进。"; + public static final String LOG_SUBSCRIPTION_FAILED_TO_REPLAY_DELIVERY_BATCH_ARG_FROM_WAL_RETAINING_UNCOMMITTED_STATE_0654EACD = + "Subscription 无法从 WAL 重放交付批次 {};保留未提交状态"; } diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusLogToTabletConverter.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusLogToTabletConverter.java index ab80b997061..8fd79a6a08b 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusLogToTabletConverter.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusLogToTabletConverter.java @@ -104,6 +104,18 @@ public class ConsensusLogToTabletConverter { return databaseName; } + /** Freezes the column projection used by a delivered batch for later WAL replay. */ + ConsensusLogToTabletConverter snapshotForReplay() { + return new ConsensusLogToTabletConverter( + treePattern, tablePattern, getColumnFilterMatcher(), databaseName); + } + + boolean hasSameReplayProjection(final ConsensusLogToTabletConverter other) { + return treePattern == other.treePattern + && tablePattern == other.tablePattern + && fallbackColumnFilterMatcher == other.fallbackColumnFilterMatcher; + } + boolean isTableModel() { return Objects.nonNull(tablePattern); } @@ -126,6 +138,16 @@ public class ConsensusLogToTabletConverter { * DataRegionStateMachine.grabPlanNode()}. */ static InsertNode deserializeToInsertNode(final IndexedConsensusRequest indexedRequest) { + return deserializeToInsertNode(indexedRequest, false); + } + + /** A payload can be discarded only after every fragment, including the seal, is readable. */ + static InsertNode deserializeCompleteInsertNode(final IndexedConsensusRequest indexedRequest) { + return deserializeToInsertNode(indexedRequest, true); + } + + private static InsertNode deserializeToInsertNode( + final IndexedConsensusRequest indexedRequest, final boolean requireComplete) { final List<SearchNode> searchNodes = new ArrayList<>(); PlanNode nonSearchNode = null; @@ -151,6 +173,9 @@ public class ConsensusLogToTabletConverter { indexedRequest.getSearchIndex(), e.getMessage(), e); + if (requireComplete) { + return null; + } continue; } @@ -173,6 +198,9 @@ public class ConsensusLogToTabletConverter { } if (!searchNodes.isEmpty()) { + if (requireComplete && !searchNodes.get(searchNodes.size() - 1).isLastFragment()) { + return null; + } final PlanNode merged = searchNodes.get(0).merge(searchNodes); if (merged instanceof InsertNode) { final InsertNode mergedInsert = (InsertNode) merged; diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueue.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueue.java index 63c06f06960..db4aa2a96b9 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueue.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueue.java @@ -61,6 +61,7 @@ import org.apache.iotdb.rpc.subscription.config.TopicConstant; import org.apache.iotdb.rpc.subscription.payload.poll.ErrorPayload; import org.apache.iotdb.rpc.subscription.payload.poll.RegionProgress; import org.apache.iotdb.rpc.subscription.payload.poll.SubscriptionCommitContext; +import org.apache.iotdb.rpc.subscription.payload.poll.SubscriptionPollResponse; import org.apache.iotdb.rpc.subscription.payload.poll.SubscriptionPollResponseType; import org.apache.iotdb.rpc.subscription.payload.poll.TabletsPayload; import org.apache.iotdb.rpc.subscription.payload.poll.WatermarkPayload; @@ -72,8 +73,10 @@ import org.apache.tsfile.write.record.Tablet; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; +import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -88,6 +91,7 @@ import java.util.PriorityQueue; import java.util.Set; import java.util.TreeMap; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentSkipListMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.LinkedBlockingDeque; import java.util.concurrent.PriorityBlockingQueue; @@ -195,6 +199,15 @@ public class ConsensusPrefetchingQueue { private final Map<SubscriptionCommitContext, SubscriptionEvent> outstandingEventsByCommitContext = new ConcurrentHashMap<>(); + /** + * Compact batch boundaries and source positions; no Tablet or serialized response is retained. + */ + private final Map<SubscriptionEvent, DeliveryBatchRecord> deliveryBatches = + new ConcurrentHashMap<>(); + + private final ConcurrentSkipListMap<Long, Integer> deliveryStartIndices = + new ConcurrentSkipListMap<>(); + /** Tablet memory retained by this queue across all materialized lifecycle stages. */ private final AtomicLong retainedTabletBytes = new AtomicLong(0L); @@ -1462,6 +1475,25 @@ public class ConsensusPrefetchingQueue { continue; } + synchronized (event) { + // ACK may have removed a recycled event while it was being claimed from the queue. + if (event.isCommitted() + || (event.getCommitContext().hasWriterProgress() + && outstandingEventsByCommitContext.get(event.getCommitContext()) != event)) { + continue; + } + if (!restorePayloadForDelivery(event)) { + prefetchingQueue.add(event); + continue; + } + final DeliveryBatchRecord batchRecord = deliveryBatches.get(event); + if (Objects.nonNull(batchRecord)) { + batchRecord.delivered = false; + // A fresh response identity fences a late delivery callback from an earlier poll. + event.restorePayload(event.getCurrentResponse().getPayload()); + } + } + // Mark as polled before updating inFlightEvents event.recordLastPolledTimestamp(); timeTracker.recordDelivery(); @@ -1638,6 +1670,7 @@ public class ConsensusPrefetchingQueue { } applyPendingSubscriptionWalReset(observedSeekGeneration); + reclaimDeliveredPayloads(); recycleInFlightEvents(); if (!isActive) { @@ -2450,7 +2483,10 @@ public class ConsensusPrefetchingQueue { if (maxTs > maxObservedTimestamp) { maxObservedTimestamp = maxTs; } - final List<Tablet> tablets = converter.convert(insertNode); + final ConsensusLogToTabletConverter snapshot = converter.snapshotForReplay(); + final ConsensusLogToTabletConverter replayConverter = + Objects.nonNull(snapshot) ? snapshot : converter; + final List<Tablet> tablets = replayConverter.convert(insertNode); final long estimatedBytes = estimateTabletsBytes(tablets); if (debugEnabled) { LOGGER.debug( @@ -2468,7 +2504,13 @@ public class ConsensusPrefetchingQueue { return null; } return new PreparedEntry( - tablets, estimatedBytes, physicalTime, writerNodeId, localSeq, searchIndex); + tablets, + estimatedBytes, + physicalTime, + writerNodeId, + localSeq, + searchIndex, + replayConverter); } private static long estimateTabletSize(final Tablet tablet) { @@ -2482,6 +2524,24 @@ public class ConsensusPrefetchingQueue { final long commitLocalSeq, final long retainedBytes, final long expectedSeekGeneration) { + return createAndEnqueueEvent( + tablets, + startSearchIndex, + endSearchIndex, + commitLocalSeq, + retainedBytes, + expectedSeekGeneration, + null); + } + + private boolean createAndEnqueueEvent( + final List<Tablet> tablets, + final long startSearchIndex, + final long endSearchIndex, + final long commitLocalSeq, + final long retainedBytes, + final long expectedSeekGeneration, + final DeliveryBatchRecord batchRecord) { if (closeRequested || isClosed) { return false; } @@ -2525,6 +2585,11 @@ public class ConsensusPrefetchingQueue { // Install the ownership record before exposing the event to concurrent poll/ack threads. retainEventMemory(event, retainedBytes); + if (Objects.nonNull(batchRecord)) { + deliveryBatches.put(event, batchRecord); + deliveryStartIndices.merge(batchRecord.minimumSearchIndex, 1, Integer::sum); + event.setResponseDeliveryListener(response -> onResponseDelivered(event, response)); + } prefetchingQueue.add(event); LOGGER.debug( @@ -2696,11 +2761,161 @@ public class ConsensusPrefetchingQueue { } } + private void onResponseDelivered( + final SubscriptionEvent event, final SubscriptionPollResponse response) { + acquireReadLock(); + try { + synchronized (event) { + final DeliveryBatchRecord record = deliveryBatches.get(event); + if (isClosed + || closeRequested + || pendingSeekRequest != null + || Objects.isNull(record) + || event.isCommitted() + || event.getCurrentResponse() != response + || outstandingEventsByCommitContext.get(event.getCommitContext()) != event) { + return; + } + record.delivered = true; + reclaimDeliveredPayload(event, record); + } + } finally { + releaseReadLock(); + } + requestPrefetch(); + } + + private void reclaimDeliveredPayloads() { + // Only materialized events need a check. The number of these is bounded by the memory budget. + for (final SubscriptionEvent event : retainedBytesByEvent.keySet()) { + synchronized (event) { + final DeliveryBatchRecord record = deliveryBatches.get(event); + if (Objects.nonNull(record) && record.delivered && !event.isCommitted()) { + reclaimDeliveredPayload(event, record); + } + } + } + } + + private void reclaimDeliveredPayload( + final SubscriptionEvent event, final DeliveryBatchRecord record) { + if (event.isPayloadDetached() || !record.delivered) { + return; + } + // Pending entries can precede durable WAL visibility. Verify every member and fragment before + // releasing the only payload copy. This read does not change replay or committed progress. + if (!record.replayable && Objects.isNull(readDeliveryBatch(event, record, false))) { + return; + } + record.replayable = true; + if (event.detachCurrentPayload(event.getCurrentResponse())) { + final Long retainedBytes = retainedBytesByEvent.remove(event); + if (Objects.nonNull(retainedBytes)) { + releaseTabletMemory(retainedBytes); + } + } + } + + private boolean restorePayloadForDelivery(final SubscriptionEvent event) { + if (!event.isPayloadDetached()) { + return true; + } + final DeliveryBatchRecord record = deliveryBatches.get(event); + if (Objects.isNull(record) || !tryReserveTabletMemory(record.estimatedBytes)) { + return false; + } + boolean restored = false; + try { + final List<Tablet> tablets = readDeliveryBatch(event, record, true); + if (Objects.isNull(tablets) + || tablets.size() != record.tabletCount + || estimateTabletsBytes(tablets) > record.estimatedBytes) { + return false; + } + event.restorePayload( + new TabletsPayload( + Collections.singletonMap(record.databaseName, tablets), -record.tabletCount)); + retainedBytesByEvent.put(event, record.estimatedBytes); + restored = true; + return true; + } finally { + if (!restored) { + releaseTabletMemory(record.estimatedBytes); + } + } + } + + /** Reads exactly the recorded members in delivery order, including non-local writer records. */ + private List<Tablet> readDeliveryBatch( + final SubscriptionEvent event, final DeliveryBatchRecord record, final boolean materialize) { + final List<ReplayEntryPosition> positions = record.decodePositions(); + final Map<WriterProgress, Integer> members = new HashMap<>(); + for (int index = 0; index < positions.size(); index++) { + final ReplayEntryPosition position = positions.get(index); + members.put(new WriterProgress(position.physicalTime, position.localSeq), index); + } + final List<List<Tablet>> tabletsByMember = new ArrayList<>(positions.size()); + for (int index = 0; index < positions.size(); index++) { + tabletsByMember.add(null); + } + final ProgressWALIterator iterator = createSubscriptionWALIterator(record.minimumSearchIndex); + if (Objects.isNull(iterator)) { + return null; + } + try (final ProgressWALIterator ignored = iterator) { + while (!members.isEmpty() && iterator.hasNext()) { + if (closeRequested || isClosed || isCommitContextOutdated(event.getCommitContext())) { + return null; + } + final IndexedConsensusRequest request = iterator.next(); + if (request.getNodeId() != event.getCommitContext().getWriterId().getNodeId()) { + continue; + } + final WriterProgress progress = toWriterProgress(request); + final Integer memberIndex = members.get(progress); + if (Objects.isNull(memberIndex)) { + continue; + } + final ReplayEntryPosition position = positions.get(memberIndex); + if (request.getPhysicalTime() != position.physicalTime + || request.getSearchIndex() != position.searchIndex) { + continue; + } + final InsertNode node = + ConsensusLogToTabletConverter.deserializeCompleteInsertNode(request); + if (Objects.isNull(node)) { + return null; + } + tabletsByMember.set( + memberIndex, materialize ? record.converter.convert(node) : Collections.emptyList()); + members.remove(progress); + } + if (!members.isEmpty() || iterator.hasIncompleteScan()) { + return null; + } + final List<Tablet> tablets = new ArrayList<>(); + tabletsByMember.forEach(tablets::addAll); + return tablets; + } catch (final IOException | RuntimeException e) { + LOGGER.warn( + DataNodePipeMessages + .LOG_SUBSCRIPTION_FAILED_TO_REPLAY_DELIVERY_BATCH_ARG_FROM_WAL_RETAINING_UNCOMMITTED_STATE_0654EACD, + event.getCommitContext(), + e); + return null; + } + } + private void cleanUpEvent(final SubscriptionEvent event, final boolean force) { synchronized (event) { final boolean indexed = outstandingEventsByCommitContext.remove(event.getCommitContext(), event); final Long retainedBytes = retainedBytesByEvent.remove(event); + final DeliveryBatchRecord batchRecord = deliveryBatches.remove(event); + if (Objects.nonNull(batchRecord)) { + deliveryStartIndices.computeIfPresent( + batchRecord.minimumSearchIndex, (index, count) -> count == 1 ? null : count - 1); + } // A materialized data event can be observed by a regular ACK and a late ACK concurrently. // Only the thread that still owns either lifecycle index may clean and release it. if (!indexed @@ -2796,6 +3011,8 @@ public class ConsensusPrefetchingQueue { } retainedBytesByEvent.clear(); outstandingEventsByCommitContext.clear(); + deliveryBatches.clear(); + deliveryStartIndices.clear(); releaseTabletMemory(retainedBytes); } @@ -2862,10 +3079,17 @@ public class ConsensusPrefetchingQueue { // SubscriptionCommitContext and RegionProgress remain per-writer. final boolean writerChanged = !batchState.isEmpty() && batchState.writerNodeId != entry.getWriterNodeId(); + final boolean projectionChanged = + !batchState.isEmpty() + && Objects.nonNull(batchState.replayConverter) + && Objects.nonNull(entry.getReplayConverter()) + && batchState.replayConverter != entry.getReplayConverter() + && !batchState.replayConverter.hasSameReplayProjection(entry.getReplayConverter()); return !(wouldExceedEntryLimit || wouldExceedTabletLimit || wouldExceedByteLimit - || writerChanged); + || writerChanged + || projectionChanged); } private boolean drainRealtimeWriters( @@ -2963,7 +3187,8 @@ public class ConsensusPrefetchingQueue { batchState.endSearchIndex, batchState.lastLocalSeq, batchState.estimatedBytes, - expectedSeekGeneration)) { + expectedSeekGeneration, + batchState.createDeliveryRecord(converter))) { return false; } resetBatchWriterProgress(); @@ -3122,6 +3347,7 @@ public class ConsensusPrefetchingQueue { || (result == InFlightAckResult.MISSING && ackMissingInFlightEvent(commitContext, false)); if (acked) { + serverImpl.checkAndUpdateSafeDeletedSearchIndex(); requestPrefetch(); } return acked; @@ -3209,6 +3435,7 @@ public class ConsensusPrefetchingQueue { || (result == InFlightAckResult.MISSING && ackMissingInFlightEvent(commitContext, true)); if (ackedResult) { + serverImpl.checkAndUpdateSafeDeletedSearchIndex(); requestPrefetch(); } return ackedResult; @@ -3719,7 +3946,20 @@ public class ConsensusPrefetchingQueue { // Committed progress can advance on another consumer or replica while this queue is still // replaying an older local search index. Keep both boundaries so WAL deletion can never pass // the earliest entry this queue has not inspected yet. - return Math.min(getCommittedRetainedMinVersionId(), getReplayRetainedMinVersionId()); + long requiredVersion = + Math.min(getCommittedRetainedMinVersionId(), getReplayRetainedMinVersionId()); + // A replica's committed frontier may advance independently. Local delivery records remain + // replayable until their lifecycle is cleaned, even after another replica reports progress. + if (consensusReqReader instanceof WALNode) { + final Map.Entry<Long, Integer> firstDelivery = deliveryStartIndices.firstEntry(); + if (Objects.nonNull(firstDelivery)) { + final File[] walFiles = ((WALNode) consensusReqReader).getSortedWalFilesSnapshot(); + requiredVersion = + Math.min( + requiredVersion, findReplayRetainedMinVersionId(walFiles, firstDelivery.getKey())); + } + } + return requiredVersion; } private static boolean isProgressAtLeast( @@ -5012,12 +5252,22 @@ public class ConsensusPrefetchingQueue { long getSearchIndex(); + ConsensusLogToTabletConverter getReplayConverter(); + OrderingKey getOrderingKey(); } private static final class DeliveryBatchState { private final List<Tablet> tablets = new ArrayList<>(); + private final ByteArrayOutputStream replayPositions = new ByteArrayOutputStream(); + private long previousSearchIndex; + private long previousPhysicalTime; + private long previousLocalSeq; + private long minimumSearchIndex; + private boolean hasNonLocalEntry; + private boolean replayEligible; + private ConsensusLogToTabletConverter replayConverter; private long startSearchIndex; private long endSearchIndex; private long estimatedBytes; @@ -5048,6 +5298,7 @@ public class ConsensusPrefetchingQueue { firstTabletTimeMs = System.currentTimeMillis(); } writerNodeId = entry.getWriterNodeId(); + replayConverter = entry.getReplayConverter(); } if (entry.getSearchIndex() >= 0) { if (startSearchIndex < 0) { @@ -5056,6 +5307,17 @@ public class ConsensusPrefetchingQueue { endSearchIndex = entry.getSearchIndex(); } tablets.addAll(entry.getTablets()); + DeliveryBatchRecord.writeDelta(replayPositions, entry.getSearchIndex() - previousSearchIndex); + DeliveryBatchRecord.writeDelta( + replayPositions, entry.getPhysicalTime() - previousPhysicalTime); + DeliveryBatchRecord.writeDelta(replayPositions, entry.getLocalSeq() - previousLocalSeq); + previousSearchIndex = entry.getSearchIndex(); + previousPhysicalTime = entry.getPhysicalTime(); + previousLocalSeq = entry.getLocalSeq(); + minimumSearchIndex = Math.min(minimumSearchIndex, entry.getSearchIndex()); + hasNonLocalEntry |= entry.getSearchIndex() < 0; + replayEligible &= + entry.getPhysicalTime() >= 0 && entry.getLocalSeq() >= 0 && entry.getWriterNodeId() >= 0; estimatedBytes += entryEstimatedBytes; physicalTime = entry.getPhysicalTime(); lastLocalSeq = entry.getLocalSeq(); @@ -5063,8 +5325,31 @@ public class ConsensusPrefetchingQueue { entryCount++; } + private DeliveryBatchRecord createDeliveryRecord( + final ConsensusLogToTabletConverter converter) { + if (!replayEligible || entryCount == 0) { + return null; + } + return new DeliveryBatchRecord( + replayPositions.toByteArray(), + entryCount, + hasNonLocalEntry ? Long.MIN_VALUE : minimumSearchIndex, + tablets.size(), + estimatedBytes, + converter.getDatabaseName(), + Objects.nonNull(replayConverter) ? replayConverter : converter); + } + private void reset() { tablets.clear(); + replayPositions.reset(); + previousSearchIndex = 0L; + previousPhysicalTime = 0L; + previousLocalSeq = 0L; + minimumSearchIndex = Long.MAX_VALUE; + hasNonLocalEntry = false; + replayEligible = true; + replayConverter = null; startSearchIndex = -1L; endSearchIndex = -1L; estimatedBytes = 0L; @@ -5076,6 +5361,86 @@ public class ConsensusPrefetchingQueue { } } + private static final class ReplayEntryPosition { + private final long searchIndex; + private final long physicalTime; + private final long localSeq; + + private ReplayEntryPosition( + final long searchIndex, final long physicalTime, final long localSeq) { + this.searchIndex = searchIndex; + this.physicalTime = physicalTime; + this.localSeq = localSeq; + } + } + + private static final class DeliveryBatchRecord { + // Delta + zigzag varints keep contiguous indices/sequences and timestamp deltas compact. + // Positions are decoded only while verifying or materializing one memory-bounded batch. + private final byte[] positions; + private final int entryCount; + private final long minimumSearchIndex; + private final int tabletCount; + private final long estimatedBytes; + private final String databaseName; + private final ConsensusLogToTabletConverter converter; + private boolean delivered; + private boolean replayable; + + private DeliveryBatchRecord( + final byte[] positions, + final int entryCount, + final long minimumSearchIndex, + final int tabletCount, + final long estimatedBytes, + final String databaseName, + final ConsensusLogToTabletConverter converter) { + this.positions = positions; + this.entryCount = entryCount; + this.minimumSearchIndex = minimumSearchIndex; + this.tabletCount = tabletCount; + this.estimatedBytes = estimatedBytes; + this.databaseName = databaseName; + this.converter = converter; + } + + private List<ReplayEntryPosition> decodePositions() { + final ByteBuffer buffer = ByteBuffer.wrap(positions); + final List<ReplayEntryPosition> result = new ArrayList<>(entryCount); + long searchIndex = 0L; + long physicalTime = 0L; + long localSeq = 0L; + for (int index = 0; index < entryCount; index++) { + searchIndex += readDelta(buffer); + physicalTime += readDelta(buffer); + localSeq += readDelta(buffer); + result.add(new ReplayEntryPosition(searchIndex, physicalTime, localSeq)); + } + return result; + } + + private static void writeDelta(final ByteArrayOutputStream stream, final long delta) { + long encoded = (delta << 1) ^ (delta >> 63); + while ((encoded & ~0x7FL) != 0L) { + stream.write((int) (encoded & 0x7F) | 0x80); + encoded >>>= 7; + } + stream.write((int) encoded); + } + + private static long readDelta(final ByteBuffer buffer) { + long encoded = 0L; + int shift = 0; + byte value; + do { + value = buffer.get(); + encoded |= (long) (value & 0x7F) << shift; + shift += 7; + } while (value < 0); + return (encoded >>> 1) ^ -(encoded & 1L); + } + } + private static final class WriterState { private long effectiveSafePt = 0L; private boolean closed = false; @@ -5088,6 +5453,7 @@ public class ConsensusPrefetchingQueue { private final int writerNodeId; private final long localSeq; private final long searchIndex; + private final ConsensusLogToTabletConverter replayConverter; private PreparedEntry( final List<Tablet> tablets, @@ -5096,12 +5462,24 @@ public class ConsensusPrefetchingQueue { final int writerNodeId, final long localSeq, final long searchIndex) { + this(tablets, estimatedBytes, physicalTime, writerNodeId, localSeq, searchIndex, null); + } + + private PreparedEntry( + final List<Tablet> tablets, + final long estimatedBytes, + final long physicalTime, + final int writerNodeId, + final long localSeq, + final long searchIndex, + final ConsensusLogToTabletConverter replayConverter) { this.tablets = tablets; this.estimatedBytes = estimatedBytes; this.physicalTime = physicalTime; this.writerNodeId = writerNodeId; this.localSeq = localSeq; this.searchIndex = searchIndex; + this.replayConverter = replayConverter; } @Override @@ -5134,6 +5512,11 @@ public class ConsensusPrefetchingQueue { return searchIndex; } + @Override + public ConsensusLogToTabletConverter getReplayConverter() { + return replayConverter; + } + @Override public OrderingKey getOrderingKey() { return new OrderingKey(physicalTime, writerNodeId, localSeq); diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/SubscriptionEvent.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/SubscriptionEvent.java index 2e58a68411d..6457a61811d 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/SubscriptionEvent.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/SubscriptionEvent.java @@ -35,6 +35,7 @@ import org.apache.iotdb.db.subscription.event.response.SubscriptionEventTsFileRe import org.apache.iotdb.rpc.subscription.payload.poll.SubscriptionCommitContext; import org.apache.iotdb.rpc.subscription.payload.poll.SubscriptionPollPayload; import org.apache.iotdb.rpc.subscription.payload.poll.SubscriptionPollResponse; +import org.apache.iotdb.rpc.subscription.payload.poll.TabletsPayload; import org.apache.thrift.annotation.Nullable; import org.slf4j.Logger; @@ -43,10 +44,12 @@ import org.slf4j.LoggerFactory; import java.io.File; import java.io.IOException; import java.nio.ByteBuffer; +import java.util.Collections; import java.util.Map; import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; import static com.google.common.base.MoreObjects.toStringHelper; @@ -60,6 +63,9 @@ public class SubscriptionEvent implements Comparable<SubscriptionEvent> { private final SubscriptionEventResponse<CachedSubscriptionPollResponse> response; private final SubscriptionCommitContext commitContext; + private volatile boolean payloadDetached; + private volatile Consumer<SubscriptionPollResponse> responseDeliveryListener; + // lastPolledConsumerId is not used as a criterion for determining pollability private volatile String lastPolledConsumerId = null; private final AtomicLong lastPolledTimestamp = new AtomicLong(INVALID_TIMESTAMP); @@ -210,13 +216,53 @@ public class SubscriptionEvent implements Comparable<SubscriptionEvent> { * {@link ConcurrentHashMap#compute} method of inFlightEvents in {@link * SubscriptionPrefetchingQueue} or {@link SubscriptionPrefetchingQueue#cleanUp}. */ - public void cleanUp(final boolean force) { + public synchronized void cleanUp(final boolean force) { pipeEvents.cleanUp(force); response.cleanUp(); + responseDeliveryListener = null; // TODO: clean more fields } + /** Marks this event's serialized response as safe for a broker to detach, if supported. */ + public void markResponseDelivered(final SubscriptionPollResponse deliveredResponse) { + final Consumer<SubscriptionPollResponse> listener = responseDeliveryListener; + if (listener != null) { + listener.accept(deliveredResponse); + } + } + + public void setResponseDeliveryListener(final Consumer<SubscriptionPollResponse> listener) { + responseDeliveryListener = listener; + } + + public boolean isPayloadDetached() { + return payloadDetached; + } + + /** Detaches the tablet payload while preserving commit and response metadata. */ + public synchronized boolean detachCurrentPayload( + final SubscriptionPollResponse deliveredResponse) { + final CachedSubscriptionPollResponse currentResponse = response.getCurrentResponse(); + if (payloadDetached + || !(response instanceof SubscriptionEventSingleResponse) + || currentResponse != deliveredResponse + || !(currentResponse.getPayload() instanceof TabletsPayload)) { + return false; + } + final int nextOffset = ((TabletsPayload) currentResponse.getPayload()).getNextOffset(); + ((SubscriptionEventSingleResponse) response) + .replaceCurrentPayload(new TabletsPayload(Collections.emptyMap(), nextOffset)); + payloadDetached = true; + return true; + } + + /** Restores a detached payload before redelivery. */ + public synchronized void restorePayload(final SubscriptionPollPayload payload) { + ((SubscriptionEventSingleResponse) response).replaceCurrentPayload(payload); + payloadDetached = false; + } + //////////////////////////// pollable //////////////////////////// public void recordLastPolledTimestamp() { @@ -262,7 +308,7 @@ public class SubscriptionEvent implements Comparable<SubscriptionEvent> { > SubscriptionConfig.getInstance().getSubscriptionRecycleUncommittedEventIntervalMs(); } - public void nack() { + public synchronized void nack() { // nack response response.nack(); @@ -313,14 +359,28 @@ public class SubscriptionEvent implements Comparable<SubscriptionEvent> { response.trySerializeRemainingResponses(); } - public void trySerializeCurrentResponse() { - response.trySerializeCurrentResponse(); + public synchronized void trySerializeCurrentResponse() { + if (!payloadDetached) { + response.trySerializeCurrentResponse(); + } } - public ByteBuffer getCurrentResponseByteBuffer() throws IOException { + public synchronized ByteBuffer getCurrentResponseByteBuffer() throws IOException { + if (payloadDetached) { + throw new IOException(DataNodeMiscMessages.NULL_RESPONSE_WHEN_SERIALIZING); + } return response.getCurrentResponseByteBuffer(); } + /** Serializes the selected response while fencing payload replacement and cleanup. */ + public synchronized ByteBuffer getResponseByteBuffer( + final SubscriptionPollResponse selectedResponse) throws IOException { + if (selectedResponse != response.getCurrentResponse()) { + throw new IOException(DataNodeMiscMessages.NULL_RESPONSE_WHEN_SERIALIZING); + } + return getCurrentResponseByteBuffer(); + } + public void invalidateCurrentResponseByteBuffer() { response.invalidateCurrentResponseByteBuffer(); } diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/cache/CachedSubscriptionPollResponse.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/cache/CachedSubscriptionPollResponse.java index 63f1fb347b1..2c25792b66b 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/cache/CachedSubscriptionPollResponse.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/cache/CachedSubscriptionPollResponse.java @@ -84,6 +84,7 @@ public class CachedSubscriptionPollResponse extends SubscriptionPollResponse { public void closeMemoryBlock() { if (Objects.nonNull(memoryBlock)) { memoryBlock.close(); + memoryBlock = null; } } diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/response/SubscriptionEventSingleResponse.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/response/SubscriptionEventSingleResponse.java index f9cb9db971c..d16044c6f2d 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/response/SubscriptionEventSingleResponse.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/response/SubscriptionEventSingleResponse.java @@ -41,7 +41,7 @@ import java.util.Objects; public class SubscriptionEventSingleResponse implements SubscriptionEventResponse<CachedSubscriptionPollResponse> { - private final CachedSubscriptionPollResponse response; + private volatile CachedSubscriptionPollResponse response; public SubscriptionEventSingleResponse( final short responseType, @@ -109,6 +109,19 @@ public class SubscriptionEventSingleResponse SubscriptionPollResponseCache.getInstance().invalidate(response); } + public void replaceCurrentPayload(final SubscriptionPollPayload payload) { + final CachedSubscriptionPollResponse previous = response; + SubscriptionPollResponseCache.getInstance().invalidate(previous); + previous.closeMemoryBlock(); + response = + new CachedSubscriptionPollResponse( + previous.getResponseType(), + payload, + previous.getCommitContext(), + previous.isTimeSelected(), + previous.getTimeSelectedByTable()); + } + @Override public void nack() { invalidateCurrentResponseByteBuffer(); diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/receiver/SubscriptionReceiverV1.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/receiver/SubscriptionReceiverV1.java index dd897f3a162..7e9b94b508e 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/receiver/SubscriptionReceiverV1.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/receiver/SubscriptionReceiverV1.java @@ -825,10 +825,10 @@ public class SubscriptionReceiverV1 implements SubscriptionReceiver { } try { - final ByteBuffer byteBuffer = event.getCurrentResponseByteBuffer(); + final ByteBuffer byteBuffer = event.getResponseByteBuffer(response); // payload size control - final long size = event.getCurrentResponseSize(); + final long size = byteBuffer.remaining(); if (totalSize.get() + size > maxBytes) { throw new SubscriptionPayloadExceedException( String.format( @@ -868,6 +868,9 @@ public class SubscriptionReceiverV1 implements SubscriptionReceiver { response, req.getRequest()); } + // The buffer now owns the serialized bytes. Notify outside the event monitor + // so queue lifecycle locks always precede the event's payload lock. + event.markResponseDelivered(response); return byteBuffer; } catch (final Exception e) { final boolean isOutdated = diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusLogToTabletConverterTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusLogToTabletConverterTest.java index e7c1f21cdb8..1cb1b05b3a5 100644 --- a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusLogToTabletConverterTest.java +++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusLogToTabletConverterTest.java @@ -62,6 +62,31 @@ public class ConsensusLogToTabletConverterTest { private static final String DATABASE_NAME = "db"; + @Test + public void testReplayRequiresCompleteAndDecodableWalFragments() { + final List<IConsensusRequest> fragments = new ArrayList<>(); + final RelationalInsertRowNode node = StatementTestUtils.genInsertRowNode(1); + node.setSearchIndex(1); + final WALInfoEntry entry = new WALInfoEntry(1L, node); + final ByteBuffer buffer = ByteBuffer.allocate(entry.serializedSize()); + entry.serialize(new WALByteBufferForTest(buffer)); + buffer.flip(); + fragments.add(new IoTConsensusRequest(buffer)); + final IndexedConsensusRequest request = new IndexedConsensusRequest(1, fragments); + Assert.assertNull(ConsensusLogToTabletConverter.deserializeCompleteInsertNode(request)); + + node.setLastFragment(true); + final WALInfoEntry completeEntry = new WALInfoEntry(1L, node); + final ByteBuffer complete = ByteBuffer.allocate(completeEntry.serializedSize()); + completeEntry.serialize(new WALByteBufferForTest(complete)); + complete.flip(); + request.getRequests().set(0, new IoTConsensusRequest(complete)); + Assert.assertNotNull(ConsensusLogToTabletConverter.deserializeCompleteInsertNode(request)); + + request.getRequests().add(0, new IoTConsensusRequest(ByteBuffer.allocate(1))); + Assert.assertNull(ConsensusLogToTabletConverter.deserializeCompleteInsertNode(request)); + } + @Test public void testRepeatedWalDeserializationPreservesAllFragments() { final List<IConsensusRequest> fragments = new ArrayList<>(); diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueueDeliveryTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueueDeliveryTest.java new file mode 100644 index 00000000000..3cf1f6556f1 --- /dev/null +++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusPrefetchingQueueDeliveryTest.java @@ -0,0 +1,627 @@ +/* + * 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.iotdb.db.subscription.broker.consensus; + +import org.apache.iotdb.commons.conf.CommonConfig; +import org.apache.iotdb.commons.conf.CommonDescriptor; +import org.apache.iotdb.commons.consensus.DataRegionId; +import org.apache.iotdb.consensus.common.request.IndexedConsensusRequest; +import org.apache.iotdb.consensus.iot.IoTConsensusServerImpl; +import org.apache.iotdb.consensus.iot.SubscriptionWalRetentionPolicy; +import org.apache.iotdb.consensus.iot.WriterSafeFrontierTracker; +import org.apache.iotdb.db.conf.IoTDBDescriptor; +import org.apache.iotdb.db.pipe.resource.memory.PipeMemoryWeightUtil; +import org.apache.iotdb.db.queryengine.plan.planner.plan.node.write.InsertNode; +import org.apache.iotdb.db.queryengine.plan.planner.plan.node.write.RelationalInsertRowNode; +import org.apache.iotdb.db.queryengine.plan.statement.StatementTestUtils; +import org.apache.iotdb.db.storageengine.dataregion.wal.buffer.WALInfoEntry; +import org.apache.iotdb.db.storageengine.dataregion.wal.io.WALFileVersion; +import org.apache.iotdb.db.storageengine.dataregion.wal.io.WALMetaData; +import org.apache.iotdb.db.storageengine.dataregion.wal.io.WALWriter; +import org.apache.iotdb.db.storageengine.dataregion.wal.node.WALNode; +import org.apache.iotdb.db.storageengine.dataregion.wal.utils.WALByteBufferForTest; +import org.apache.iotdb.db.storageengine.dataregion.wal.utils.WALFileStatus; +import org.apache.iotdb.db.storageengine.dataregion.wal.utils.WALFileUtils; +import org.apache.iotdb.db.subscription.event.SubscriptionEvent; +import org.apache.iotdb.db.subscription.resource.SubscriptionMemoryManager; +import org.apache.iotdb.rpc.subscription.config.TopicConstant; +import org.apache.iotdb.rpc.subscription.payload.poll.RegionProgress; +import org.apache.iotdb.rpc.subscription.payload.poll.SubscriptionCommitContext; +import org.apache.iotdb.rpc.subscription.payload.poll.SubscriptionPollResponse; +import org.apache.iotdb.rpc.subscription.payload.poll.TabletsPayload; +import org.apache.iotdb.rpc.subscription.payload.poll.WriterId; +import org.apache.iotdb.rpc.subscription.payload.poll.WriterProgress; + +import org.apache.tsfile.enums.ColumnCategory; +import org.apache.tsfile.enums.TSDataType; +import org.apache.tsfile.write.record.Tablet; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +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.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class ConsensusPrefetchingQueueDeliveryTest { + + private static final int WRITER = 7; + private static final String CONSUMER = "consumer"; + private static final DataRegionId REGION = new DataRegionId(1); + + @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private final CommonConfig config = CommonDescriptor.getInstance().getConfig(); + private final List<ConsensusPrefetchingQueue> queues = new ArrayList<>(); + private String originalSystemDir; + private int originalMaxDelay; + private int originalMaxTablets; + private int originalMaxEntries; + private long originalMaxBytes; + private File walDirectory; + private WALNode walNode; + private ConsensusLogToTabletConverter converter; + private ConsensusSubscriptionCommitManager commitManager; + private SubscriptionMemoryManager memoryManager; + private AtomicLong currentSearchIndex; + private long tabletBytes; + + @Before + public void setUp() throws Exception { + originalSystemDir = IoTDBDescriptor.getInstance().getConfig().getSystemDir(); + originalMaxDelay = config.getSubscriptionConsensusBatchMaxDelayInMs(); + originalMaxTablets = config.getSubscriptionConsensusBatchMaxTabletCount(); + originalMaxEntries = config.getSubscriptionConsensusBatchMaxWalEntries(); + originalMaxBytes = config.getSubscriptionConsensusBatchMaxSizeInBytes(); + config.setSubscriptionConsensusBatchMaxDelayInMs(0); + config.setSubscriptionConsensusBatchMaxTabletCount(1); + config.setSubscriptionConsensusBatchMaxWalEntries(128); + config.setSubscriptionConsensusBatchMaxSizeInBytes(16 * 1024 * 1024); + IoTDBDescriptor.getInstance() + .getConfig() + .setSystemDir(temporaryFolder.newFolder("system").getAbsolutePath()); + walDirectory = temporaryFolder.newFolder("wal"); + currentSearchIndex = new AtomicLong(); + walNode = mock(WALNode.class); + when(walNode.getLogDirectory()).thenReturn(walDirectory); + when(walNode.getCurrentSearchIndex()).thenAnswer(ignored -> currentSearchIndex.get()); + when(walNode.getCurrentWALFileVersion()).thenReturn(1L); + when(walNode.getCurrentWALMetaDataSnapshot()).thenReturn(new WALMetaData()); + when(walNode.getSortedWalFilesSnapshot()) + .thenAnswer( + ignored -> { + final File[] files = WALFileUtils.listAllWALFiles(walDirectory); + WALFileUtils.ascSortByVersionId(files); + return files; + }); + converter = mock(ConsensusLogToTabletConverter.class); + when(converter.getDatabaseName()).thenReturn("db"); + when(converter.isTableModel()).thenReturn(true); + when(converter.convert(any())) + .thenAnswer( + invocation -> + Collections.singletonList( + tablet(((InsertNode) invocation.getArgument(0)).getPhysicalTime()))); + tabletBytes = PipeMemoryWeightUtil.calculateTabletSizeInBytes(tablet(1001)); + memoryManager = new SubscriptionMemoryManager(tabletBytes * 2); + commitManager = + new ConsensusSubscriptionCommitManager( + (group, topic, region) -> + ConsensusSubscriptionCommitManager.ConfigNodeProgressQueryResult.absent()); + } + + @After + public void tearDown() { + try { + queues.forEach(ConsensusPrefetchingQueue::close); + } finally { + config.setSubscriptionConsensusBatchMaxDelayInMs(originalMaxDelay); + config.setSubscriptionConsensusBatchMaxTabletCount(originalMaxTablets); + config.setSubscriptionConsensusBatchMaxWalEntries(originalMaxEntries); + config.setSubscriptionConsensusBatchMaxSizeInBytes(originalMaxBytes); + IoTDBDescriptor.getInstance().getConfig().setSystemDir(originalSystemDir); + } + } + + @Test + public void testTenDeliveriesWithTwoBatchBudgetAndDelayedCommit() throws Exception { + writeWal(10, true); + final ConsensusPrefetchingQueue queue = newQueue(); + assertNull(queue.poll(CONSUMER)); + final List<SubscriptionCommitContext> contexts = new ArrayList<>(); + for (long index = 1; index <= 10; index++) { + queue.drivePrefetchOnce(); + final SubscriptionEvent event = queue.poll(CONSUMER); + assertNotNull(event); + assertEquals(index, event.getCommitContext().getWriterProgress().getLocalSeq()); + assertEquals(Collections.singletonList(1000 + index), timestamps(deliver(event))); + assertTrue(event.isPayloadDetached()); + assertTrue(((TabletsPayload) event.getCurrentResponse().getPayload()).getTablets().isEmpty()); + assertThrows(IOException.class, event::getCurrentResponseByteBuffer); + contexts.add(event.getCommitContext()); + assertTrue(memoryManager.getUsedMemorySizeInBytes() <= tabletBytes * 2); + assertEquals(queue.getRetainedTabletBytes(), memoryManager.getUsedMemorySizeInBytes()); + assertTrue(committedProgress().getWriterPositions().isEmpty()); + assertEquals(index, queue.getSubscriptionUncommittedEventCount()); + assertEquals(0L, requiredRetainedVersion(queue)); + } + + // An out-of-order ACK still waits for the preceding nine batches. + assertTrue(queue.ack(CONSUMER, contexts.get(9))); + assertTrue(committedProgress().getWriterPositions().isEmpty()); + for (int index = 0; index < 9; index++) { + assertTrue(queue.ack(CONSUMER, contexts.get(index))); + } + assertEquals( + new WriterProgress(1010, 10), committedProgress().getWriterPositions().get(writerId())); + assertEquals(0L, queue.getSubscriptionUncommittedEventCount()); + assertEquals(0L, queue.getRetainedTabletBytes()); + assertEquals(1L, requiredRetainedVersion(queue)); + assertEquals(0, deliveryRecordCount(queue)); + } + + @Test + public void testNackTimeoutDisconnectAndLateAckAfterReclamation() throws Exception { + writeWal(1, true); + final ConsensusPrefetchingQueue queue = newQueue(); + assertNull(queue.poll(CONSUMER)); + queue.drivePrefetchOnce(); + final SubscriptionEvent event = queue.poll(CONSUMER); + final SubscriptionCommitContext context = event.getCommitContext(); + final SubscriptionPollResponse first = deliver(event); + assertEquals(0L, queue.getRetainedTabletBytes()); + + assertTrue(queue.nack(CONSUMER, context)); + assertSame(event, queue.poll(CONSUMER)); + assertFalse(event.isPayloadDetached()); + assertEquals(tabletBytes, queue.getRetainedTabletBytes()); + final SubscriptionPollResponse second = deliver(event); + assertEquals(first.getPayload(), second.getPayload()); + assertEquals(first.getCommitContext(), second.getCommitContext()); + + // Force the timeout without sleeping or changing the process-wide recycle interval. + final Field timestamp = SubscriptionEvent.class.getDeclaredField("lastPolledTimestamp"); + timestamp.setAccessible(true); + ((AtomicLong) timestamp.get(event)).set(0L); + queue.drivePrefetchOnce(); + assertSame(event, queue.poll("replacement")); + assertEquals(first.getPayload(), deliver(event).getPayload()); + + assertEquals(1, queue.requeueInFlightEvents("replacement")); + assertSame(event, queue.poll("replacement")); + assertEquals(first.getPayload(), deliver(event).getPayload()); + assertEquals(1, queue.requeueInFlightEvents("replacement")); + // A late ACK may commit a queued, detached event without reading WAL again. + assertTrue(queue.ack("replacement", context)); + assertNull(queue.poll(CONSUMER)); + assertEquals(0, deliveryRecordCount(queue)); + assertEquals(0L, memoryManager.getUsedMemorySizeInBytes()); + } + + @Test + public void testIncompleteOrNotYetVisibleWalKeepsPayloadUntilReplayable() throws Exception { + currentSearchIndex.set(1); + final ConsensusPrefetchingQueue queue = newQueue(); + assertNull(queue.poll(CONSUMER)); + assertTrue(pendingEntries(queue).offer(request(1, 1, WRITER))); + queue.drivePrefetchOnce(); + final SubscriptionEvent event = queue.poll(CONSUMER); + deliver(event); + assertFalse(event.isPayloadDetached()); + assertEquals(tabletBytes, queue.getRetainedTabletBytes()); + + writeWal(1, false); + queue.drivePrefetchOnce(); + assertFalse(event.isPayloadDetached()); + assertEquals(tabletBytes, queue.getRetainedTabletBytes()); + + writeWal(1, true); + queue.drivePrefetchOnce(); + assertTrue(event.isPayloadDetached()); + assertEquals(0L, queue.getRetainedTabletBytes()); + assertTrue(committedProgress().getWriterPositions().isEmpty()); + assertTrue(queue.ack(CONSUMER, event.getCommitContext())); + } + + @Test + public void testUnreadableReplayKeepsRecordAndRetriesWithoutAdvancingCommit() throws Exception { + writeWal(1, true); + final ConsensusPrefetchingQueue queue = newQueue(); + assertNull(queue.poll(CONSUMER)); + queue.drivePrefetchOnce(); + final SubscriptionEvent event = queue.poll(CONSUMER); + final SubscriptionPollResponse first = deliver(event); + assertTrue(queue.nack(CONSUMER, event.getCommitContext())); + final File hidden = new File(temporaryFolder.getRoot(), "hidden.wal"); + Files.move(dataWal().toPath(), hidden.toPath()); + assertNull(queue.poll(CONSUMER)); + assertTrue(event.isPayloadDetached()); + assertEquals(1, deliveryRecordCount(queue)); + assertEquals(0L, queue.getRetainedTabletBytes()); + assertTrue(committedProgress().getWriterPositions().isEmpty()); + Files.move(hidden.toPath(), dataWal().toPath()); + assertSame(event, queue.poll(CONSUMER)); + assertEquals(first.getPayload(), deliver(event).getPayload()); + assertTrue(queue.ack(CONSUMER, event.getCommitContext())); + } + + @Test + public void testSparseBatchReplaysOnlyItsOriginalMembers() throws Exception { + config.setSubscriptionConsensusBatchMaxTabletCount(8); + config.setSubscriptionConsensusBatchMaxDelayInMs(60_000); + memoryManager = new SubscriptionMemoryManager(tabletBytes * 8); + writeWal(3, true); + final ConsensusPrefetchingQueue queue = newQueue(); + assertNull(queue.poll(CONSUMER)); + // Build a batch with members 1 and 3, as writer reordering can do for interleaved WAL records. + // Replaying the inclusive [1, 3] range would silently add entry 2 to the redelivery. + final Field linger = ConsensusPrefetchingQueue.class.getDeclaredField("lingerBatch"); + linger.setAccessible(true); + final Object batch = linger.get(queue); + final Class<?> entryClass = + Class.forName(ConsensusPrefetchingQueue.class.getName() + "$PreparedEntry"); + final Constructor<?> constructor = + entryClass.getDeclaredConstructor( + List.class, long.class, long.class, int.class, long.class, long.class); + constructor.setAccessible(true); + final Method append = + batch + .getClass() + .getDeclaredMethod( + "append", + Class.forName(ConsensusPrefetchingQueue.class.getName() + "$WriterBufferedEntry"), + long.class, + boolean.class); + append.setAccessible(true); + final Method reserve = + ConsensusPrefetchingQueue.class.getDeclaredMethod("tryReserveTabletMemory", long.class); + reserve.setAccessible(true); + for (final long index : new long[] {1, 3}) { + assertTrue((boolean) reserve.invoke(queue, tabletBytes)); + append.invoke( + batch, + constructor.newInstance( + Collections.singletonList(tablet(1000 + index)), + tabletBytes, + 1000 + index, + WRITER, + index, + index), + tabletBytes, + true); + } + final Method flush = + ConsensusPrefetchingQueue.class.getDeclaredMethod( + "flushBatch", batch.getClass(), long.class); + flush.setAccessible(true); + assertTrue((boolean) flush.invoke(queue, batch, queue.getCurrentSeekGeneration())); + final SubscriptionEvent event = queue.poll(CONSUMER); + final SubscriptionPollResponse first = deliver(event); + assertEquals(Arrays.asList(1001L, 1003L), timestamps(first)); + assertTrue(event.isPayloadDetached()); + assertTrue(queue.nack(CONSUMER, event.getCommitContext())); + assertSame(event, queue.poll(CONSUMER)); + assertEquals(first.getPayload(), deliver(event).getPayload()); + } + + @Test + public void testRestartReplaysAllUncommittedBatchesFromCommittedProgress() throws Exception { + writeWal(10, true); + ConsensusPrefetchingQueue queue = newQueue(); + assertNull(queue.poll(CONSUMER)); + for (int index = 1; index <= 10; index++) { + queue.drivePrefetchOnce(); + final SubscriptionEvent event = queue.poll(CONSUMER); + deliver(event); + if (index <= 2) { + assertTrue(queue.ack(CONSUMER, event.getCommitContext())); + } + } + final RegionProgress committed = committedProgress(); + commitManager.persistAll(); + queue.close(); + assertEquals(0, deliveryRecordCount(queue)); + + commitManager = + new ConsensusSubscriptionCommitManager( + (group, topic, region) -> + ConsensusSubscriptionCommitManager.ConfigNodeProgressQueryResult.unavailable()); + queue = newQueue(); + assertNull(queue.poll(CONSUMER)); + final List<Long> received = new ArrayList<>(); + for (int index = 3; index <= 10; index++) { + queue.drivePrefetchOnce(); + final SubscriptionEvent event = queue.poll(CONSUMER); + assertNotNull(event); + received.addAll(timestamps(deliver(event))); + assertTrue(queue.ack(CONSUMER, event.getCommitContext())); + } + assertEquals(Arrays.asList(1003L, 1004L, 1005L, 1006L, 1007L, 1008L, 1009L, 1010L), received); + assertEquals(new WriterProgress(1002, 2), committed.getWriterPositions().get(writerId())); + assertEquals( + new WriterProgress(1010, 10), committedProgress().getWriterPositions().get(writerId())); + } + + @Test + public void testReplicatedEntryWithoutLocalIndexReplaysByWriterPosition() throws Exception { + writeWal(1, true, false); + final ConsensusPrefetchingQueue queue = newQueue(); + assertNull(queue.poll(CONSUMER)); + queue.drivePrefetchOnce(); + final SubscriptionEvent event = queue.poll(CONSUMER); + final SubscriptionPollResponse first = deliver(event); + assertTrue(event.isPayloadDetached()); + assertEquals(0L, queue.getRetainedTabletBytes()); + assertEquals(0L, requiredRetainedVersion(queue)); + assertTrue(queue.nack(CONSUMER, event.getCommitContext())); + assertSame(event, queue.poll(CONSUMER)); + assertEquals(first.getPayload(), deliver(event).getPayload()); + assertTrue(queue.ack(CONSUMER, event.getCommitContext())); + } + + @Test + public void testSeekResetDiscardsDetachedStateAndRejectsOldCallbacksAndAcks() throws Exception { + writeWal(1, true); + final ConsensusPrefetchingQueue queue = newQueue(); + assertNull(queue.poll(CONSUMER)); + queue.drivePrefetchOnce(); + final SubscriptionEvent event = queue.poll(CONSUMER); + final SubscriptionPollResponse oldResponse = event.getCurrentResponse(); + deliver(event); + assertTrue(event.isPayloadDetached()); + + // Install the request a public seek hands to the serial worker. Drive it synchronously so the + // test does not depend on a process-wide executor being enabled. + final long oldGeneration = queue.getCurrentSeekGeneration(); + final Class<?> requestClass = + Class.forName(ConsensusPrefetchingQueue.class.getName() + "$PendingSeekRequest"); + final Constructor<?> constructor = + requestClass.getDeclaredConstructor( + long.class, RegionProgress.class, String.class, boolean.class, long.class, long.class); + constructor.setAccessible(true); + final Field generation = ConsensusPrefetchingQueue.class.getDeclaredField("seekGeneration"); + generation.setAccessible(true); + ((AtomicLong) generation.get(queue)).incrementAndGet(); + final Field pendingSeek = + ConsensusPrefetchingQueue.class.getDeclaredField("pendingSeekRequest"); + pendingSeek.setAccessible(true); + pendingSeek.set( + queue, + constructor.newInstance( + 1L, + new RegionProgress(Collections.emptyMap()), + "beginning", + true, + oldGeneration, + oldGeneration + 1)); + queue.drivePrefetchOnce(); + + assertEquals(0, deliveryRecordCount(queue)); + assertEquals(0L, memoryManager.getUsedMemorySizeInBytes()); + event.markResponseDelivered(oldResponse); + assertFalse(queue.ack(CONSUMER, event.getCommitContext())); + queue.drivePrefetchOnce(); + final SubscriptionEvent replayed = queue.poll(CONSUMER); + assertEquals(Collections.singletonList(1001L), timestamps(deliver(replayed))); + assertTrue(queue.ack(CONSUMER, replayed.getCommitContext())); + } + + @Test + public void testLateDeliveryCallbackCannotDetachAnotherAttemptAndCloseWaitsForSerialization() + throws Exception { + writeWal(1, true); + final ConsensusPrefetchingQueue queue = newQueue(); + assertNull(queue.poll(CONSUMER)); + queue.drivePrefetchOnce(); + final SubscriptionEvent event = queue.poll(CONSUMER); + final SubscriptionPollResponse oldResponse = event.getCurrentResponse(); + event.getResponseByteBuffer(oldResponse); + assertTrue(queue.requeue(CONSUMER, event.getCommitContext())); + assertSame(event, queue.poll(CONSUMER)); + event.markResponseDelivered(oldResponse); + assertFalse(event.isPayloadDetached()); + assertEquals(tabletBytes, queue.getRetainedTabletBytes()); + + final ExecutorService executor = Executors.newSingleThreadExecutor(); + final CountDownLatch started = new CountDownLatch(1); + try { + final Future<?> cleanup; + synchronized (event) { + cleanup = + executor.submit( + () -> { + started.countDown(); + queue.cleanUp(); + }); + assertTrue(started.await(5, TimeUnit.SECONDS)); + assertFalse(cleanup.isDone()); + assertNotNull(event.getCurrentResponseByteBuffer()); + } + cleanup.get(5, TimeUnit.SECONDS); + event.markResponseDelivered(oldResponse); + assertEquals(0, deliveryRecordCount(queue)); + assertEquals(0L, memoryManager.getUsedMemorySizeInBytes()); + } finally { + executor.shutdownNow(); + } + } + + private ConsensusPrefetchingQueue newQueue() { + final IoTConsensusServerImpl server = mock(IoTConsensusServerImpl.class); + when(server.getConsensusReqReader()).thenReturn(walNode); + when(server.getWriterSafeFrontierTracker()).thenReturn(new WriterSafeFrontierTracker()); + final ConsensusPrefetchingQueue queue = + new ConsensusPrefetchingQueue( + "group", + "topic", + TopicConstant.ORDER_MODE_LEADER_ONLY_VALUE, + REGION, + server, + new SubscriptionWalRetentionPolicy( + "topic", + SubscriptionWalRetentionPolicy.UNBOUNDED, + SubscriptionWalRetentionPolicy.UNBOUNDED), + converter, + commitManager, + new RegionProgress(Collections.emptyMap()), + 1L, + 1L, + true); + queue.setSubscriptionMemoryManager(memoryManager); + queues.add(queue); + return queue; + } + + private void writeWal(final int count, final boolean complete) throws Exception { + writeWal(count, complete, true); + } + + private void writeWal(final int count, final boolean complete, final boolean local) + throws Exception { + currentSearchIndex.set(local ? count : 0); + // WALWriter appends to existing files; replacing a visibility fixture needs a fresh segment. + Files.deleteIfExists(dataWal().toPath()); + try (WALWriter writer = new WALWriter(dataWal(), WALFileVersion.V3)) { + for (long index = 1; index <= count; index++) { + final RelationalInsertRowNode node = StatementTestUtils.genInsertRowNode((int) index); + node.setSearchIndex(local ? index : -1L); + node.setLastFragment(complete); + final WALInfoEntry entry = new WALInfoEntry(1L, node); + final ByteBuffer buffer = ByteBuffer.allocate(entry.serializedSize()); + entry.serialize(new WALByteBufferForTest(buffer)); + final WALMetaData metadata = new WALMetaData(); + metadata.add(entry.serializedSize(), local ? index : -1L, 1L, 1000 + index, WRITER, index); + writer.write(buffer, metadata); + } + } + try (WALWriter ignored = + new WALWriter( + new File( + walDirectory, + WALFileUtils.getLogFileName(1L, count, WALFileStatus.CONTAINS_SEARCH_INDEX)), + WALFileVersion.V3)) { + // The data file is sealed; the live successor contains no requests. + } + } + + private File dataWal() { + return new File( + walDirectory, WALFileUtils.getLogFileName(0L, 0L, WALFileStatus.CONTAINS_SEARCH_INDEX)); + } + + private static IndexedConsensusRequest request( + final long index, final long sequence, final int writer) { + return new IndexedConsensusRequest( + index, + sequence, + Collections.singletonList(StatementTestUtils.genInsertRowNode((int) sequence))) + .setPhysicalTime(1000 + sequence) + .setNodeId(writer); + } + + private static Tablet tablet(final long timestamp) { + final Tablet tablet = + new Tablet( + "sensors", + Arrays.asList("device", "temperature"), + Arrays.asList(TSDataType.STRING, TSDataType.DOUBLE), + Arrays.asList(ColumnCategory.TAG, ColumnCategory.FIELD), + 1); + tablet.addTimestamp(0, timestamp); + tablet.addValue(0, 0, "d1"); + tablet.addValue(0, 1, (double) timestamp); + tablet.setRowSize(1); + return tablet; + } + + private static SubscriptionPollResponse deliver(final SubscriptionEvent event) throws Exception { + final SubscriptionPollResponse response = event.getCurrentResponse(); + final ByteBuffer bytes = event.getResponseByteBuffer(response); + event.invalidateCurrentResponseByteBuffer(); + event.markResponseDelivered(response); + return SubscriptionPollResponse.deserialize(bytes.duplicate()); + } + + private static List<Long> timestamps(final SubscriptionPollResponse response) { + final List<Long> result = new ArrayList<>(); + for (final Tablet tablet : ((TabletsPayload) response.getPayload()).getTablets()) { + result.add(tablet.getTimestamps()[0]); + } + return result; + } + + private RegionProgress committedProgress() { + return commitManager.getCommittedRegionProgress("group", "topic", REGION); + } + + private static WriterId writerId() { + return new WriterId(REGION.toString(), WRITER); + } + + private static long requiredRetainedVersion(final ConsensusPrefetchingQueue queue) + throws Exception { + final Method method = + ConsensusPrefetchingQueue.class.getDeclaredMethod("getRequiredRetainedMinVersionId"); + method.setAccessible(true); + return (long) method.invoke(queue); + } + + private static int deliveryRecordCount(final ConsensusPrefetchingQueue queue) throws Exception { + final Field field = ConsensusPrefetchingQueue.class.getDeclaredField("deliveryBatches"); + field.setAccessible(true); + return ((java.util.Map<?, ?>) field.get(queue)).size(); + } + + @SuppressWarnings("unchecked") + private static BlockingQueue<IndexedConsensusRequest> pendingEntries( + final ConsensusPrefetchingQueue queue) throws Exception { + final Field field = ConsensusPrefetchingQueue.class.getDeclaredField("pendingEntries"); + field.setAccessible(true); + return (BlockingQueue<IndexedConsensusRequest>) field.get(queue); + } +}
