Skip to content

Commit 8de8158

Browse files
authored
[server][da-vinci] Honor IngestionPauseMode by unsubscribing Kafka consumers (linkedin#2763)
1 parent 07bb5d5 commit 8de8158

14 files changed

Lines changed: 790 additions & 21 deletions

File tree

clients/da-vinci-client/src/main/java/com/linkedin/davinci/kafka/consumer/LeaderFollowerStoreIngestionTask.java

Lines changed: 159 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
import com.linkedin.venice.compression.CompressionStrategy;
4747
import com.linkedin.venice.exceptions.VeniceException;
4848
import com.linkedin.venice.exceptions.VeniceMessageException;
49+
import com.linkedin.venice.exceptions.VeniceNoStoreException;
4950
import com.linkedin.venice.exceptions.VeniceTimeoutException;
5051
import com.linkedin.venice.exceptions.validation.DuplicateDataException;
5152
import com.linkedin.venice.exceptions.validation.FatalDataValidationException;
@@ -620,6 +621,7 @@ protected void checkLongRunningTaskState() throws InterruptedException {
620621
boolean pushTimeout = false;
621622
Set<Integer> timeoutPartitions = null;
622623
long checkStartTimeInNS = System.nanoTime();
624+
maybeTransitionPauseState();
623625
for (PartitionConsumptionState partitionConsumptionState: getPartitionConsumptionStateMap().values()) {
624626
final int partition = partitionConsumptionState.getPartition();
625627

@@ -675,7 +677,7 @@ protected void checkLongRunningTaskState() throws InterruptedException {
675677
* online replica continue serving and do not close ingestion task.
676678
*/
677679
if (!partitionConsumptionState.isComplete() && !partitionConsumptionState.isErrorReported()
678-
&& LatencyUtils.getElapsedTimeFromMsToMs(
680+
&& !partitionConsumptionState.isStoreLevelPaused() && LatencyUtils.getElapsedTimeFromMsToMs(
679681
partitionConsumptionState.getConsumptionStartTimeInMs()) > getBootstrapTimeoutInMs()) {
680682
if (!pushTimeout) {
681683
pushTimeout = true;
@@ -871,6 +873,143 @@ protected void checkLongRunningTaskState() throws InterruptedException {
871873
getVersionTopic().getName());
872874
}
873875

876+
/**
877+
* Reconciles each PCS's store-level pause state against the current store metadata.
878+
* <p>
879+
* On a transition into paused: removes the partition's current leader topic (or VT for
880+
* followers) from every per-broker consumer service via {@link #consumerUnSubscribeAllTopics}.
881+
* Note: cross-region RT subscriptions for an A/A leader are managed by the leader's normal A/A
882+
* code path, not by this hook — only the topic in the OffsetRecord is dropped here.
883+
* On a transition out of paused: resubscribes via {@link #resubscribe}, which rewinds from the
884+
* persisted offset to the leader topic recorded in the OffsetRecord (followers: local VT;
885+
* leaders: the recorded leader topic).
886+
* <p>
887+
* The PCS pause flag is set to its target value <em>before</em> the long-running unsubscribe /
888+
* resubscribe so the disk-quota no-op guard covers the entire transition window. Each PCS is
889+
* processed inside a try/catch so a failure on one partition does not abandon the others.
890+
*/
891+
void maybeTransitionPauseState() throws InterruptedException {
892+
Store store;
893+
try {
894+
store = storeRepository.getStoreOrThrow(storeName);
895+
} catch (VeniceNoStoreException e) {
896+
// Store metadata is genuinely unavailable (deleted, not yet propagated, etc.). Leave pause
897+
// state as-is and retry next iteration. Throttle WARN via the inherited filter so a
898+
// continuously-failing lookup logs at most once per window instead of once per SIT
899+
// iteration. Other RuntimeExceptions propagate so real bugs surface.
900+
if (!REDUNDANT_LOGGING_FILTER.isRedundantException(storeName + "-maybeTransitionPauseState-store-not-found")) {
901+
LOGGER.warn("Store {} not found while reconciling pause state; skipping transition.", storeName);
902+
}
903+
return;
904+
}
905+
boolean shouldPause = shouldPauseForStore(store);
906+
boolean transitioned = false;
907+
// Lazily probe the consumer subscription state once per loop (SIT-wide), only when a
908+
// RECONCILE_FORCE_UNSUBSCRIBE could fire (shouldPause is set). consumerHasAnySubscription is
909+
// a single fast call against aggKafkaConsumerService; consumerUnSubscribeAllTopics itself is
910+
// self-gating per topic, so triggering reconcile when only some partitions are subscribed is
911+
// benign (no-ops for the others).
912+
boolean anySubscriptionForSit = shouldPause && consumerHasAnySubscription();
913+
for (PartitionConsumptionState pcs: getPartitionConsumptionStateMap().values()) {
914+
PauseStateTransition transition = decidePauseTransition(pcs, shouldPause, anySubscriptionForSit);
915+
if (transition == PauseStateTransition.NO_CHANGE) {
916+
continue;
917+
}
918+
try {
919+
if (transition == PauseStateTransition.ENTER_PAUSE) {
920+
// Flip the flag BEFORE the long-running unsubscribe so concurrent disk-quota callbacks
921+
// see the new state and no-op for the entire window.
922+
pcs.setStoreLevelPaused(true);
923+
consumerUnSubscribeAllTopics(pcs);
924+
LOGGER.info(
925+
"Store-level pause activated for replica: {} — unsubscribed from Kafka",
926+
Utils.getReplicaId(getKafkaVersionTopic(), pcs.getPartition()));
927+
} else if (transition == PauseStateTransition.RECONCILE_FORCE_UNSUBSCRIBE) {
928+
consumerUnSubscribeAllTopics(pcs);
929+
LOGGER.info(
930+
"Store-level pause re-applied for replica: {} — subscription was reattached, unsubscribed again",
931+
Utils.getReplicaId(getKafkaVersionTopic(), pcs.getPartition()));
932+
} else { // EXIT_PAUSE
933+
// Resubscribe BEFORE clearing the flag so quota callbacks stay no-op until the consumer
934+
// is back online; if resubscribe throws we leave the flag set so the next iteration
935+
// retries instead of leaving the partition dark.
936+
resubscribe(pcs);
937+
pcs.setStoreLevelPaused(false);
938+
pcs.resetConsumptionStartTimeInMs();
939+
LOGGER.info(
940+
"Store-level pause deactivated for replica: {} — resubscribed from persisted offset",
941+
Utils.getReplicaId(getKafkaVersionTopic(), pcs.getPartition()));
942+
}
943+
transitioned = true;
944+
} catch (InterruptedException e) {
945+
Thread.currentThread().interrupt();
946+
throw e;
947+
} catch (Exception e) {
948+
LOGGER.error(
949+
"Failed to apply pause transition {} for replica: {}; will retry next iteration.",
950+
transition,
951+
Utils.getReplicaId(getKafkaVersionTopic(), pcs.getPartition()),
952+
e);
953+
}
954+
}
955+
if (transitioned) {
956+
// Reflect actual post-loop state, not intent — partial-failure cases shouldn't flip the
957+
// gauge to 0 while some PCSes remain paused.
958+
boolean anyPcsPaused = false;
959+
for (PartitionConsumptionState pcs: getPartitionConsumptionStateMap().values()) {
960+
if (pcs != null && pcs.isStoreLevelPaused()) {
961+
anyPcsPaused = true;
962+
break;
963+
}
964+
}
965+
versionedIngestionStats.setStoreLevelPausedGauge(storeName, versionNumber, anyPcsPaused);
966+
}
967+
}
968+
969+
/**
970+
* Result of evaluating a single PCS against the desired pause state.
971+
* {@code RECONCILE_FORCE_UNSUBSCRIBE} fires when the PCS is already flagged paused but a
972+
* subscription has crept back (e.g., a leader/follower transition or topic switch reattached
973+
* the consumer); the reconcile loop force-unsubscribes again so ingestion can't resume while
974+
* shouldPause is still true.
975+
*/
976+
enum PauseStateTransition {
977+
ENTER_PAUSE, EXIT_PAUSE, RECONCILE_FORCE_UNSUBSCRIBE, NO_CHANGE
978+
}
979+
980+
/**
981+
* Pure decision function: given the current PCS pause flag, the desired pause state, and
982+
* whether the partition still has any active Kafka subscription, return the transition that
983+
* needs to happen (if any). Side-effect-free and trivially unit-testable.
984+
*
985+
* <p>{@code RECONCILE_FORCE_UNSUBSCRIBE} fires when {@code shouldPause} is true and the PCS is
986+
* already flagged paused but {@code hasAnyActiveSubscription} reports a live subscription —
987+
* makes the reconcile loop idempotent against out-of-band re-subscriptions (L/F transitions,
988+
* topic switches) that would otherwise leave a paused store ingesting.
989+
*
990+
* <p>Returns NO_CHANGE for a {@code null} PCS — defensive guard mirroring
991+
* {@link StoreIngestionTask#shouldSkipQuotaCallbackForStoreLevelPause(PartitionConsumptionState)}.
992+
*/
993+
static PauseStateTransition decidePauseTransition(
994+
PartitionConsumptionState pcs,
995+
boolean shouldPause,
996+
boolean hasAnyActiveSubscription) {
997+
if (pcs == null) {
998+
return PauseStateTransition.NO_CHANGE;
999+
}
1000+
boolean isPaused = pcs.isStoreLevelPaused();
1001+
if (shouldPause && !isPaused) {
1002+
return PauseStateTransition.ENTER_PAUSE;
1003+
}
1004+
if (shouldPause && isPaused && hasAnyActiveSubscription) {
1005+
return PauseStateTransition.RECONCILE_FORCE_UNSUBSCRIBE;
1006+
}
1007+
if (!shouldPause && pcs.isStoreLevelPaused()) {
1008+
return PauseStateTransition.EXIT_PAUSE;
1009+
}
1010+
return PauseStateTransition.NO_CHANGE;
1011+
}
1012+
8741013
protected static boolean checkWhetherToCloseUnusedVeniceWriter(
8751014
Lazy<VeniceWriter<byte[], byte[], byte[]>> veniceWriterLazy,
8761015
Lazy<VeniceWriter<byte[], byte[], byte[]>> veniceWriterForRealTimeLazy,
@@ -3457,15 +3596,29 @@ protected void consumerBatchUnsubscribeAllTopics() {
34573596
*/
34583597
@Override
34593598
public void consumerUnSubscribeAllTopics(PartitionConsumptionState partitionConsumptionState) {
3460-
PubSubTopic leaderTopic = partitionConsumptionState.getOffsetRecord().getLeaderTopic(pubSubTopicRepository);
34613599
int partitionId = partitionConsumptionState.getPartition();
3462-
if (partitionConsumptionState.getLeaderFollowerState().equals(LEADER) && leaderTopic != null) {
3463-
aggKafkaConsumerService
3464-
.unsubscribeConsumerFor(versionTopic, new PubSubTopicPartitionImpl(leaderTopic, partitionId));
3465-
} else {
3600+
PubSubTopic leaderTopic = partitionConsumptionState.getOffsetRecord().getLeaderTopic(pubSubTopicRepository);
3601+
3602+
// During leader/follower transitions or bootstrap a partition can be transiently subscribed
3603+
// to BOTH the version topic and a separate leader topic (e.g., RT). Unsubscribe each that is
3604+
// currently subscribed; consumerHasSubscription() avoids redundant work when a topic isn't
3605+
// attached.
3606+
if (consumerHasSubscription(versionTopic, partitionConsumptionState)) {
34663607
aggKafkaConsumerService
34673608
.unsubscribeConsumerFor(versionTopic, new PubSubTopicPartitionImpl(versionTopic, partitionId));
34683609
}
3610+
if (leaderTopic != null && !leaderTopic.equals(versionTopic)
3611+
&& consumerHasSubscription(leaderTopic, partitionConsumptionState)) {
3612+
aggKafkaConsumerService
3613+
.unsubscribeConsumerFor(versionTopic, new PubSubTopicPartitionImpl(leaderTopic, partitionId));
3614+
// Gate sep-RT unsubscribe on actual subscription to avoid WARN spam from the consumer
3615+
// delegator when sep-RT isn't currently attached (common during transitions).
3616+
if (isSeparatedRealtimeTopicEnabled() && leaderTopic.isRealTime() && separateRealTimeTopic != null
3617+
&& consumerHasSubscription(separateRealTimeTopic, partitionConsumptionState)) {
3618+
aggKafkaConsumerService
3619+
.unsubscribeConsumerFor(versionTopic, new PubSubTopicPartitionImpl(separateRealTimeTopic, partitionId));
3620+
}
3621+
}
34693622

34703623
/**
34713624
* Leader of the user partition should close all subPartitions it is producing to.

clients/da-vinci-client/src/main/java/com/linkedin/davinci/kafka/consumer/PartitionConsumptionState.java

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,18 @@ enum LatchStatus {
216216
* and check timeout in the consumption state check function which runs regularly:
217217
* {@link LeaderFollowerStoreIngestionTask#checkLongRunningTaskState()}
218218
*/
219-
private final long consumptionStartTimeInMs;
219+
private long consumptionStartTimeInMs;
220+
221+
/**
222+
* Tracks whether this partition is currently paused due to a store-level
223+
* {@link com.linkedin.venice.meta.IngestionPauseMode} other than NOT_PAUSED. Distinct from the
224+
* disk-quota pause managed by {@link StorageUtilizationManager} — when this flag is true, quota
225+
* resume callbacks must no-op so they don't fight the store-level pause.
226+
* <p>
227+
* Written by the SIT thread but read by disk-quota callbacks invoked from other threads, so it
228+
* is {@code volatile} for cross-thread visibility.
229+
*/
230+
private volatile boolean storeLevelPaused = false;
220231

221232
/**
222233
* This hash map will keep a temporary mapping between a key and it's value.
@@ -956,6 +967,19 @@ public long getConsumptionStartTimeInMs() {
956967
return consumptionStartTimeInMs;
957968
}
958969

970+
/** Resets the bootstrap-timeout clock so paused time isn't counted against the window. */
971+
public void resetConsumptionStartTimeInMs() {
972+
this.consumptionStartTimeInMs = System.currentTimeMillis();
973+
}
974+
975+
public boolean isStoreLevelPaused() {
976+
return storeLevelPaused;
977+
}
978+
979+
public void setStoreLevelPaused(boolean storeLevelPaused) {
980+
this.storeLevelPaused = storeLevelPaused;
981+
}
982+
959983
public void setTransientRecord(
960984
int kafkaClusterId,
961985
PubSubPosition consumedPosition,

clients/da-vinci-client/src/main/java/com/linkedin/davinci/kafka/consumer/StorageUtilizationManager.java

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -343,13 +343,19 @@ private boolean isStorageQuotaExceeded(StoragePartitionDiskUsage partitionDiskUs
343343
* partition without affecting partition subscription
344344
*/
345345
private void pausePartition(int partition, String consumingTopic) {
346-
pausePartition.execute(consumingTopic, partition);
347-
this.pausedPartitions.add(partition);
346+
// Only record the partition as quota-paused if the underlying consumer was actually paused.
347+
// The lambda may no-op if a higher-priority pause source (e.g. store-level IngestionPauseMode)
348+
// already owns the consumer; recording it as paused anyway would desynchronize this manager
349+
// from real consumer state.
350+
if (pausePartition.execute(consumingTopic, partition)) {
351+
this.pausedPartitions.add(partition);
352+
}
348353
}
349354

350355
private void resumePartition(int partition, String consumingTopic) {
351-
resumePartition.execute(consumingTopic, partition);
352-
this.pausedPartitions.remove(partition);
356+
if (resumePartition.execute(consumingTopic, partition)) {
357+
this.pausedPartitions.remove(partition);
358+
}
353359
}
354360

355361
private void resumeAllPartitions() {

clients/da-vinci-client/src/main/java/com/linkedin/davinci/kafka/consumer/StoreIngestionTask.java

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@
9393
import com.linkedin.venice.kafka.protocol.state.StoreVersionState;
9494
import com.linkedin.venice.message.KafkaKey;
9595
import com.linkedin.venice.meta.HybridStoreConfig;
96+
import com.linkedin.venice.meta.IngestionPauseMode;
9697
import com.linkedin.venice.meta.ReadOnlySchemaRepository;
9798
import com.linkedin.venice.meta.ReadOnlyStoreRepository;
9899
import com.linkedin.venice.meta.Store;
@@ -4671,16 +4672,39 @@ public void consumerResetOffset(PubSubTopic topic, PartitionConsumptionState par
46714672
aggKafkaConsumerService.resetOffsetFor(versionTopic, new PubSubTopicPartitionImpl(topic, partitionId));
46724673
}
46734674

4674-
private void pauseConsumption(String topic, int partitionId) {
4675+
private boolean pauseConsumption(String topic, int partitionId) {
4676+
// Store-level pause takes precedence; no-op here so the two pause sources don't race.
4677+
if (shouldSkipQuotaCallbackForStoreLevelPause(partitionConsumptionStateMap.get(partitionId))) {
4678+
logQuotaCallbackSuppressed("pauseConsumption", topic, partitionId);
4679+
return false;
4680+
}
46754681
aggKafkaConsumerService.pauseConsumerFor(
46764682
versionTopic,
46774683
new PubSubTopicPartitionImpl(pubSubTopicRepository.getTopic(topic), partitionId));
4684+
return true;
46784685
}
46794686

4680-
private void resumeConsumption(String topic, int partitionId) {
4687+
private boolean resumeConsumption(String topic, int partitionId) {
4688+
// Store-level pause takes precedence; no-op here so a quota resume doesn't un-pause us.
4689+
if (shouldSkipQuotaCallbackForStoreLevelPause(partitionConsumptionStateMap.get(partitionId))) {
4690+
logQuotaCallbackSuppressed("resumeConsumption", topic, partitionId);
4691+
return false;
4692+
}
46814693
aggKafkaConsumerService.resumeConsumerFor(
46824694
versionTopic,
46834695
new PubSubTopicPartitionImpl(pubSubTopicRepository.getTopic(topic), partitionId));
4696+
return true;
4697+
}
4698+
4699+
private void logQuotaCallbackSuppressed(String callbackName, String topic, int partitionId) {
4700+
String key = storeName + "-" + callbackName + "-storeLevelPauseSuppressed";
4701+
if (!REDUNDANT_LOGGING_FILTER.isRedundantException(key)) {
4702+
LOGGER.info(
4703+
"Disk-quota {} suppressed for {}-{} because partition is store-level paused.",
4704+
callbackName,
4705+
topic,
4706+
partitionId);
4707+
}
46844708
}
46854709

46864710
/**
@@ -4753,6 +4777,42 @@ protected final void recordActiveKeyCountInvalidation() {
47534777
hostLevelIngestionStats.recordActiveKeyCountInvalidation();
47544778
}
47554779

4780+
/**
4781+
* Returns true when a disk-quota pause/resume callback must no-op because the partition is
4782+
* currently store-level paused. Pure helper to keep the branch unit-testable.
4783+
*/
4784+
static boolean shouldSkipQuotaCallbackForStoreLevelPause(PartitionConsumptionState pcs) {
4785+
return pcs != null && pcs.isStoreLevelPaused();
4786+
}
4787+
4788+
/**
4789+
* Returns true if this SIT should pause based on the store's current pause mode and, for
4790+
* {@link IngestionPauseMode#CURRENT_VERSION}, whether this SIT's version number equals
4791+
* {@link Store#getCurrentVersion()}. Applies uniformly to both Venice servers and DaVinci
4792+
* clients.
4793+
*/
4794+
boolean shouldPauseForStore(Store store) {
4795+
return shouldPauseForStore(store, versionNumber);
4796+
}
4797+
4798+
/**
4799+
* Static, side-effect-free pause decision. Exposed package-private for unit testing.
4800+
* @param store store metadata (must be non-null)
4801+
* @param sitVersionNumber the version of the SIT making the decision
4802+
* @return true if this SIT should pause Kafka consumption
4803+
*/
4804+
static boolean shouldPauseForStore(Store store, int sitVersionNumber) {
4805+
IngestionPauseMode mode = store.getIngestionPauseMode();
4806+
if (mode == null || mode == IngestionPauseMode.NOT_PAUSED) {
4807+
return false;
4808+
}
4809+
if (mode == IngestionPauseMode.ALL_VERSIONS) {
4810+
return true;
4811+
}
4812+
// CURRENT_VERSION: pause only if this SIT's version is the current one
4813+
return sitVersionNumber == store.getCurrentVersion();
4814+
}
4815+
47564816
/**
47574817
* Write the kafka message to the underlying storage engine.
47584818
* @param consumerRecord
@@ -6130,6 +6190,26 @@ && getResubscribeRequestQueue().peek() != null) {
61306190
continue;
61316191
}
61326192

6193+
/**
6194+
* Skip partitions whose store is currently paused. Resubscribing here would re-attach the
6195+
* Kafka consumer that the store-level pause has deliberately torn down; maybeTransitionPauseState
6196+
* would then re-unsubscribe it on the next reconcile tick, but the brief window allows
6197+
* records to leak through. The reconcile loop will resubscribe via {@link #resubscribe} on
6198+
* resume, so this lag-based path can safely defer.
6199+
*/
6200+
if (pcs.isStoreLevelPaused()) {
6201+
// Throttle via the shared filter — during an operational pause every lagging partition
6202+
// gets enqueued each heartbeat cycle (~60s), so without throttling a paused store with
6203+
// N partitions emits N log lines per cycle. Key on storeName so we get one signal per
6204+
// paused store per filter window instead of one per replica.
6205+
if (!REDUNDANT_LOGGING_FILTER.isRedundantException(storeName + "-skip-lag-resubscribe-store-paused")) {
6206+
LOGGER.info(
6207+
"Skipping lag-based resubscribe for replica: {} because store-level pause is active.",
6208+
pcs.getReplicaId());
6209+
}
6210+
continue;
6211+
}
6212+
61336213
/**
61346214
* Skip partitions with an in-flight blob transfer. Resubscribing reopens RocksDB on the final
61356215
* partition directory while blob transfer is still receiving files into the temp directory,

0 commit comments

Comments
 (0)