FrankChen021 commented on code in PR #19311: URL: https://github.com/apache/druid/pull/19311#discussion_r4111419687
########## extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/ShareGroupIndexTaskRunner.java: ########## @@ -0,0 +1,535 @@ +/* + * 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.druid.indexing.kafka; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Supplier; +import com.google.common.collect.ImmutableMap; +import org.apache.druid.common.config.Configs; +import org.apache.druid.data.input.Committer; +import org.apache.druid.data.input.InputFormat; +import org.apache.druid.data.input.InputRow; +import org.apache.druid.data.input.InputRowSchema; +import org.apache.druid.data.input.kafka.KafkaRecordEntity; +import org.apache.druid.data.input.kafka.KafkaTopicPartition; +import org.apache.druid.indexer.TaskStatus; +import org.apache.druid.indexer.partitions.DynamicPartitionsSpec; +import org.apache.druid.indexing.appenderator.ActionBasedPublishedSegmentRetriever; +import org.apache.druid.indexing.appenderator.ActionBasedSegmentAllocator; +import org.apache.druid.indexing.common.LockGranularity; +import org.apache.druid.indexing.common.TaskLock; +import org.apache.druid.indexing.common.TaskLockType; +import org.apache.druid.indexing.common.TaskToolbox; +import org.apache.druid.indexing.common.actions.LockReleaseAction; +import org.apache.druid.indexing.common.actions.SegmentAllocateAction; +import org.apache.druid.indexing.common.actions.SegmentLockAcquireAction; +import org.apache.druid.indexing.common.actions.SegmentTransactionalAppendAction; +import org.apache.druid.indexing.common.actions.TaskLocks; +import org.apache.druid.indexing.common.actions.TimeChunkLockAcquireAction; +import org.apache.druid.indexing.common.task.InputRowFilter; +import org.apache.druid.indexing.common.task.Tasks; +import org.apache.druid.indexing.input.InputRowSchemas; +import org.apache.druid.indexing.overlord.SegmentPublishResult; +import org.apache.druid.indexing.seekablestream.SeekableStreamAppenderatorConfig; +import org.apache.druid.indexing.seekablestream.StreamChunkReader; +import org.apache.druid.indexing.seekablestream.common.AcknowledgeType; +import org.apache.druid.indexing.seekablestream.common.AcknowledgingRecordSupplier; +import org.apache.druid.indexing.seekablestream.common.OrderedPartitionableRecord; +import org.apache.druid.java.util.common.ISE; +import org.apache.druid.java.util.common.logger.Logger; +import org.apache.druid.segment.SegmentSchemaMapping; +import org.apache.druid.segment.incremental.ParseExceptionHandler; +import org.apache.druid.segment.incremental.RowIngestionMeters; +import org.apache.druid.segment.indexing.DataSchema; +import org.apache.druid.segment.realtime.SegmentGenerationMetrics; +import org.apache.druid.segment.realtime.appenderator.Appenderator; +import org.apache.druid.segment.realtime.appenderator.AppenderatorDriverAddResult; +import org.apache.druid.segment.realtime.appenderator.SegmentIdWithShardSpec; +import org.apache.druid.segment.realtime.appenderator.SegmentsAndCommitMetadata; +import org.apache.druid.segment.realtime.appenderator.StreamAppenderatorDriver; +import org.apache.druid.segment.realtime.appenderator.TransactionalSegmentPublisher; +import org.apache.druid.timeline.DataSegment; +import org.apache.druid.timeline.partition.NumberedPartialShardSpec; +import org.apache.kafka.common.errors.WakeupException; + +import javax.annotation.Nullable; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; + +/** + * Single-threaded ingestion loop for {@link ShareGroupIndexTask}. Records are + * {@code ACCEPT}-acknowledged only after the containing segment is published; + * any earlier failure leaves them unacknowledged so the broker redelivers + * them once the acquisition lock expires. + */ +public class ShareGroupIndexTaskRunner +{ + private static final Logger log = new Logger(ShareGroupIndexTaskRunner.class); + + static final String METRIC_COMMIT_FAILURES = "ingest/shareGroup/commitFailures"; + + private final ShareGroupIndexTask task; + private final TaskToolbox toolbox; + private final ObjectMapper configMapper; + private final String sequenceName; + private final LockGranularity lockGranularity; + private final TaskLockType lockType; + private final Function<ShareGroupIndexTaskIOConfig, + AcknowledgingRecordSupplier<KafkaTopicPartition, Long, KafkaRecordEntity>> supplierFactory; + + private final AtomicReference<AcknowledgingRecordSupplier<KafkaTopicPartition, Long, KafkaRecordEntity>> + activeSupplier = new AtomicReference<>(); + + ShareGroupIndexTaskRunner( + ShareGroupIndexTask task, + TaskToolbox toolbox, + ObjectMapper configMapper + ) + { + this(task, toolbox, configMapper, null); + } + + @VisibleForTesting + ShareGroupIndexTaskRunner( + ShareGroupIndexTask task, + TaskToolbox toolbox, + ObjectMapper configMapper, + @Nullable Function<ShareGroupIndexTaskIOConfig, + AcknowledgingRecordSupplier<KafkaTopicPartition, Long, KafkaRecordEntity>> supplierFactory + ) + { + this.task = task; + this.toolbox = toolbox; + this.configMapper = configMapper; + this.sequenceName = task.getId(); + final boolean useTimeChunkLocks = Configs.valueOrDefault( + task.getContextValue(Tasks.FORCE_TIME_CHUNK_LOCK_KEY, Tasks.DEFAULT_FORCE_TIME_CHUNK_LOCK), + Tasks.DEFAULT_FORCE_TIME_CHUNK_LOCK + ); + this.lockGranularity = useTimeChunkLocks ? LockGranularity.TIME_CHUNK : LockGranularity.SEGMENT; + this.lockType = TaskLocks.determineLockTypeForAppend(task.getContext()); + this.supplierFactory = supplierFactory != null ? supplierFactory : this::createDefaultRecordSupplier; + } + + public TaskStatus run() throws Exception + { + final DataSchema dataSchema = task.getDataSchema(); + final KafkaIndexTaskTuningConfig tuningConfig = task.getTuningConfig(); + final ShareGroupIndexTaskIOConfig ioConfig = task.getIOConfig(); + + final RowIngestionMeters rowIngestionMeters = + toolbox.getRowIngestionMetersFactory().createRowIngestionMeters(); + final ParseExceptionHandler parseExceptionHandler = new ParseExceptionHandler( + rowIngestionMeters, + tuningConfig.isLogParseExceptions(), + tuningConfig.getMaxParseExceptions(), + tuningConfig.getMaxSavedParseExceptions() + ); + final SegmentGenerationMetrics segmentGenerationMetrics = new SegmentGenerationMetrics(); + + final InputRowSchema inputRowSchema = InputRowSchemas.fromDataSchema(dataSchema); + final InputFormat inputFormat = ioConfig.getInputFormat(); + if (inputFormat == null) { + throw new ISE("inputFormat must be specified in ioConfig"); + } + + // Raw type mirrors SeekableStreamIndexTaskRunner; works around + // OrderedPartitionableRecord.getData() returning a wildcard list. + @SuppressWarnings({"rawtypes", "unchecked"}) + final StreamChunkReader chunkReader = new StreamChunkReader<KafkaRecordEntity>( + inputFormat, + inputRowSchema, + dataSchema.getTransformSpec(), + toolbox.getIndexingTmpDir(), + InputRowFilter.allowAll(), + rowIngestionMeters, + parseExceptionHandler + ); + + final Appenderator appenderator = toolbox.getAppenderatorsManager().createRealtimeAppenderatorForTask( + toolbox.getSegmentLoaderConfig(), + task.getId(), + dataSchema, + SeekableStreamAppenderatorConfig.fromTuningConfig( + tuningConfig.withBasePersistDirectory(toolbox.getPersistDir()), + toolbox.getProcessingConfig() + ), + toolbox.getConfig(), + segmentGenerationMetrics, + toolbox.getSegmentPusher(), + toolbox.getJsonMapper(), + toolbox.getIndexIO(), + toolbox.getIndexMerger(), + toolbox.getQueryRunnerFactoryConglomerate(), + toolbox.getSegmentAnnouncer(), + toolbox.getEmitter(), + toolbox.getQueryProcessingPool(), + toolbox.getJoinableFactory(), + toolbox.getCache(), + toolbox.getCacheConfig(), + toolbox.getCachePopulatorStats(), + toolbox.getPolicyEnforcer(), + rowIngestionMeters, + parseExceptionHandler, + toolbox.getCentralizedTableSchemaConfig(), + interval -> { + toolbox.getTaskActionClient().submit(new LockReleaseAction(interval)); + } + ); + + final StreamAppenderatorDriver driver = new StreamAppenderatorDriver( + appenderator, + new ActionBasedSegmentAllocator( + toolbox.getTaskActionClient(), + dataSchema, + (schema, row, sequenceName, previousSegmentId, skipSegmentLineageCheck) -> new SegmentAllocateAction( + schema.getDataSource(), + row.getTimestamp(), + schema.getGranularitySpec().getQueryGranularity(), + schema.getGranularitySpec().getSegmentGranularity(), + sequenceName, + previousSegmentId, + skipSegmentLineageCheck, + NumberedPartialShardSpec.instance(), + lockGranularity, + lockType + ) + ), + toolbox.getSegmentHandoffNotifierFactory(), + new ActionBasedPublishedSegmentRetriever(toolbox.getTaskActionClient()), + toolbox.getDataSegmentKiller(), + toolbox.getJsonMapper(), + segmentGenerationMetrics + ); + + final org.apache.druid.indexing.common.stats.TaskRealtimeMetricsMonitor metricsMonitor = + new org.apache.druid.indexing.common.stats.TaskRealtimeMetricsMonitor( + segmentGenerationMetrics, + rowIngestionMeters, + task.getMetricBuilder() + ); + toolbox.addMonitor(metricsMonitor); + + boolean runLoopCompleted = false; + try (final AcknowledgingRecordSupplier<KafkaTopicPartition, Long, KafkaRecordEntity> recordSupplier = + supplierFactory.apply(ioConfig)) { + activeSupplier.set(recordSupplier); + final TaskStatus status = runLoop(driver, appenderator, recordSupplier, chunkReader, ioConfig, tuningConfig, toolbox); + runLoopCompleted = true; + return status; + } + finally { + activeSupplier.set(null); + if (!runLoopCompleted) { + try { + appenderator.closeNow(); + } + catch (Exception e) { + log.warn(e, "Exception during emergency closeNow() of Appenderator in run(); continuing teardown."); + } + } + try { + toolbox.removeMonitor(metricsMonitor); + } + catch (Exception e) { + log.warn(e, "Exception removing TaskRealtimeMetricsMonitor; continuing teardown."); + } + try { + driver.close(); + } + catch (Exception e) { + log.warn(e, "Exception closing StreamAppenderatorDriver; continuing teardown."); + } + } + } + + @VisibleForTesting + TaskStatus runLoop( + StreamAppenderatorDriver driver, + Appenderator appenderator, + AcknowledgingRecordSupplier<KafkaTopicPartition, Long, KafkaRecordEntity> recordSupplier, + StreamChunkReader chunkReader, + ShareGroupIndexTaskIOConfig ioConfig, + KafkaIndexTaskTuningConfig tuningConfig, + TaskToolbox toolbox + ) throws Exception + { + // Share groups manage delivery state on the broker; the Committer is a + // no-op placeholder required by the appenderator driver contract. + final Supplier<Committer> committerSupplier = () -> new Committer() + { + @Override + public Object getMetadata() + { + return ImmutableMap.of("type", "share_group"); + } + + @Override + public void run() + { + } + }; + + log.info("Starting share group ingestion loop for topic[%s], group[%s].", + ioConfig.getTopic(), ioConfig.getGroupId()); + + long totalRowsIngested = 0; + boolean lockTimeoutLogged = false; + boolean appenderatorClosedNormally = false; + + try { + recordSupplier.subscribe(Collections.singleton(ioConfig.getTopic())); + driver.startJob(this::acquireLockForRestoredSegment); + while (!task.isStopRequested()) { + + final List<OrderedPartitionableRecord<KafkaTopicPartition, Long, KafkaRecordEntity>> records; + try { + records = recordSupplier.poll(ioConfig.getPollTimeout()); + } + catch (WakeupException e) { + if (task.isStopRequested()) { + log.info("Wake-up received while stopGracefully is in progress; exiting ingestion loop."); + break; + } + throw e; + } + + if (!lockTimeoutLogged) { + final Optional<Integer> effectiveLockMs = recordSupplier.acquisitionLockTimeoutMs(); + log.info( + "Effective broker acquisition lock timeout for share-group[%s]: %s ms", + ioConfig.getGroupId(), + effectiveLockMs.map(String::valueOf).orElse("<unknown>") + ); + lockTimeoutLogged = true; + } + + if (records.isEmpty()) { + continue; + } + + final Map<KafkaTopicPartition, List<Long>> batchOffsets = new HashMap<>(); + boolean midBatchPersistNeeded = false; + boolean pushThresholdLogged = false; + + for (OrderedPartitionableRecord<KafkaTopicPartition, Long, KafkaRecordEntity> record : records) { + batchOffsets.computeIfAbsent(record.getPartitionId(), k -> new ArrayList<>()) + .add(record.getSequenceNumber()); + + final List<InputRow> rows = chunkReader.parse(record.getData(), false); + + for (InputRow row : rows) { + final AppenderatorDriverAddResult addResult = driver.add( + row, + sequenceName, + committerSupplier, + true, + false + ); + + if (!addResult.isOk()) { + // Throw without acknowledging so the broker redelivers this batch. + throw new ISE( + "Could not allocate segment for row with timestamp[%s]", + row.getTimestamp() + ); + } + + totalRowsIngested++; + midBatchPersistNeeded |= addResult.isPersistRequired(); + + if (!pushThresholdLogged && addResult.isPushRequired( + tuningConfig.getPartitionsSpec().getMaxRowsPerSegment(), + tuningConfig.getPartitionsSpec() + .getMaxTotalRowsOr(DynamicPartitionsSpec.DEFAULT_MAX_TOTAL_ROWS) + )) { + pushThresholdLogged = true; + log.info( + "Segment row threshold reached during batch (current segment[%s], rows[%d]); " + + "will publish at end-of-batch boundary.", + addResult.getSegmentIdentifier(), + addResult.getNumRowsInSegment() + ); + } + } + } + + if (midBatchPersistNeeded) { + driver.persist(committerSupplier.get()); + } + + driver.persist(committerSupplier.get()); + + final TransactionalSegmentPublisher publisher = new TransactionalSegmentPublisher() + { + @Override + public SegmentPublishResult publishAnnotatedSegments( + @Nullable Set<DataSegment> mustBeNullOrEmptyOverwriteSegments, + Set<DataSegment> segmentsToPush, + @Nullable Object commitMetadata, + SegmentSchemaMapping segmentSchemaMapping + ) throws IOException + { + if (segmentsToPush.isEmpty()) { + return SegmentPublishResult.ok(segmentsToPush); + } + return toolbox.getTaskActionClient().submit( + SegmentTransactionalAppendAction.forSegments(segmentsToPush, segmentSchemaMapping) + ); + } + + @Override + public boolean supportsEmptyPublish() + { + return false; + } + }; + + final SegmentsAndCommitMetadata published = driver.publish( + publisher, + committerSupplier.get(), + Collections.singletonList(sequenceName) + ).get(); + + final int segmentCount = published.getSegments().size(); + if (segmentCount > 0) { + log.info("Published %d segments with %d total rows.", segmentCount, totalRowsIngested); + } + + // Acknowledge only after the segment is published; any earlier failure + // leaves records unacknowledged so the broker redelivers them. + for (Map.Entry<KafkaTopicPartition, List<Long>> entry : batchOffsets.entrySet()) { + for (Long offset : entry.getValue()) { + recordSupplier.acknowledge(entry.getKey(), offset, AcknowledgeType.ACCEPT); + } + } + + final Map<KafkaTopicPartition, Optional<Exception>> commitResult = recordSupplier.commitSync(); + long commitFailures = 0L; + for (Map.Entry<KafkaTopicPartition, Optional<Exception>> entry : commitResult.entrySet()) { + if (entry.getValue().isPresent()) { + commitFailures++; + log.warn( + entry.getValue().get(), + "Commit failed for partition[%s]. Records may be redelivered.", + entry.getKey() + ); + } + } + if (commitFailures > 0) { + task.emitMetric(toolbox.getEmitter(), METRIC_COMMIT_FAILURES, commitFailures); + } + } + + driver.persist(committerSupplier.get()); + log.info("Share group ingestion complete. Total rows ingested: %d", totalRowsIngested); + appenderator.close(); + appenderatorClosedNormally = true; + } + finally { + if (!appenderatorClosedNormally) { + try { + appenderator.closeNow(); + } + catch (Exception e) { + log.warn(e, "Exception during emergency closeNow() of Appenderator; continuing teardown."); + } + } + } + + return TaskStatus.success(task.getId()); + } + + private boolean acquireLockForRestoredSegment(SegmentIdWithShardSpec segmentId) + { + try { + if (lockGranularity == LockGranularity.SEGMENT) { + return toolbox.getTaskActionClient().submit( + new SegmentLockAcquireAction( + lockType, Review Comment: [P2] Avoid APPEND locks with segment granularity **Finding:** When forceTimeChunkLock=false, this task uses SEGMENT lock granularity but still defaults lockType to APPEND. The restored-segment path submits that unsupported pairing to SegmentLockAcquireAction, and SegmentAllocateAction receives the same pairing; TaskLockbox can reject it when an overlapping lock exists, preventing persisted segments from restoring or new batches from allocating under normal concurrent ingestion. **Suggestion:** Use a supported lock-type/granularity pairing for segment mode and keep allocation, restoration, and publishing consistent, or explicitly reject forceTimeChunkLock=false; cover the configuration with a lockbox-backed test. ########## docs/ingestion/kafka-share-group-ingestion.md: ########## @@ -0,0 +1,349 @@ +--- +id: kafka-share-group-ingestion +title: "Kafka share group ingestion" +sidebar_label: "Kafka share group ingestion" +description: "Queue-semantics ingestion from Apache Kafka using share groups (KIP-932). Scale consumers beyond partition count with at-least-once delivery." +--- + +<!-- + ~ 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. + --> + +:::info +Requires Apache Kafka 4.0 or higher with share groups (KIP-932) enabled on the broker. +::: + +## Overview + +Kafka share groups (KIP-932) let multiple consumers read from the same partition concurrently. The broker manages per-record acquisition locks and explicit acknowledgement, so consumer count is not capped by partition count, joining or leaving consumers does not pause the group, and a slow record does not block its partition. + +Druid's `ShareGroupIndexTask` consumes from a share group and publishes segments with at-least-once delivery: records are acknowledged only after their segments are atomically registered in the metadata store. + +## When to use share group ingestion + +| Scenario | Consumer group | Share group | +|----------|---------------|-------------| +| Workers needed exceed partition count | Idle workers | All workers active | +| Elastic scaling (auto-scale events) | Rebalancing pause (30-60s) | Zero pause | +| Per-message processing time varies | Head-of-line blocking | Independent processing | +| Ordered processing required per partition | Yes | No (delivery order not guaranteed) | + +Choose share groups when throughput and elastic scaling matter more than strict per-partition ordering. + +## Task spec + +Submit a `ShareGroupIndexTask` to the Overlord. There are no start/end offsets -- the broker tracks them. + +```json +{ + "type": "index_kafka_share_group", + "dataSchema": { + "dataSource": "my_datasource", + "timestampSpec": { + "column": "__time", + "format": "auto" + }, + "dimensionsSpec": { + "useSchemaDiscovery": true + }, + "granularitySpec": { + "segmentGranularity": "DAY", + "queryGranularity": "NONE" + } + }, + "ioConfig": { + "topic": "my_topic", + "groupId": "druid-share-group", + "consumerProperties": { + "bootstrap.servers": "kafka-broker:9092" + }, + "inputFormat": { + "type": "json" + }, + "pollTimeout": 2000 + }, + "tuningConfig": { + "type": "KafkaTuningConfig", + "maxRowsPerSegment": 5000000 + } +} +``` + +## IO configuration + +| Property | Type | Required | Default | Description | +|----------|------|----------|---------|-------------| +| `topic` | String | Yes | -- | Kafka topic to consume from. | +| `groupId` | String | Yes | -- | Share group identifier. Multiple tasks with the same `groupId` share the workload. | +| `consumerProperties` | Map | Yes | -- | Kafka consumer properties. Must include `bootstrap.servers`. See [Consumer property restrictions](#consumer-property-restrictions). | +| `inputFormat` | Object | Yes | -- | Input format for parsing records (json, csv, avro, etc.). | +| `pollTimeout` | Long | No | 2000 | Poll timeout in milliseconds. | Review Comment: [P3] Document the actual pollTimeout default **Finding:** The configuration table says an omitted pollTimeout defaults to 2000 ms, but ShareGroupIndexTaskIOConfig delegates to KafkaSupervisorIOConfig.DEFAULT_POLL_TIMEOUT_MILLIS, which is 100 ms. Users following this documentation will poll twenty times more frequently than the documented default and may tune batch timing against the wrong value. **Suggestion:** Make the documentation match the implementation, or change the new configuration default and its test to the intended 2000 ms value. ########## extensions-core/kafka-indexing-service/src/main/java/org/apache/druid/indexing/kafka/ShareGroupIndexTaskRunner.java: ########## @@ -0,0 +1,535 @@ +/* + * 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.druid.indexing.kafka; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Supplier; +import com.google.common.collect.ImmutableMap; +import org.apache.druid.common.config.Configs; +import org.apache.druid.data.input.Committer; +import org.apache.druid.data.input.InputFormat; +import org.apache.druid.data.input.InputRow; +import org.apache.druid.data.input.InputRowSchema; +import org.apache.druid.data.input.kafka.KafkaRecordEntity; +import org.apache.druid.data.input.kafka.KafkaTopicPartition; +import org.apache.druid.indexer.TaskStatus; +import org.apache.druid.indexer.partitions.DynamicPartitionsSpec; +import org.apache.druid.indexing.appenderator.ActionBasedPublishedSegmentRetriever; +import org.apache.druid.indexing.appenderator.ActionBasedSegmentAllocator; +import org.apache.druid.indexing.common.LockGranularity; +import org.apache.druid.indexing.common.TaskLock; +import org.apache.druid.indexing.common.TaskLockType; +import org.apache.druid.indexing.common.TaskToolbox; +import org.apache.druid.indexing.common.actions.LockReleaseAction; +import org.apache.druid.indexing.common.actions.SegmentAllocateAction; +import org.apache.druid.indexing.common.actions.SegmentLockAcquireAction; +import org.apache.druid.indexing.common.actions.SegmentTransactionalAppendAction; +import org.apache.druid.indexing.common.actions.TaskLocks; +import org.apache.druid.indexing.common.actions.TimeChunkLockAcquireAction; +import org.apache.druid.indexing.common.task.InputRowFilter; +import org.apache.druid.indexing.common.task.Tasks; +import org.apache.druid.indexing.input.InputRowSchemas; +import org.apache.druid.indexing.overlord.SegmentPublishResult; +import org.apache.druid.indexing.seekablestream.SeekableStreamAppenderatorConfig; +import org.apache.druid.indexing.seekablestream.StreamChunkReader; +import org.apache.druid.indexing.seekablestream.common.AcknowledgeType; +import org.apache.druid.indexing.seekablestream.common.AcknowledgingRecordSupplier; +import org.apache.druid.indexing.seekablestream.common.OrderedPartitionableRecord; +import org.apache.druid.java.util.common.ISE; +import org.apache.druid.java.util.common.logger.Logger; +import org.apache.druid.segment.SegmentSchemaMapping; +import org.apache.druid.segment.incremental.ParseExceptionHandler; +import org.apache.druid.segment.incremental.RowIngestionMeters; +import org.apache.druid.segment.indexing.DataSchema; +import org.apache.druid.segment.realtime.SegmentGenerationMetrics; +import org.apache.druid.segment.realtime.appenderator.Appenderator; +import org.apache.druid.segment.realtime.appenderator.AppenderatorDriverAddResult; +import org.apache.druid.segment.realtime.appenderator.SegmentIdWithShardSpec; +import org.apache.druid.segment.realtime.appenderator.SegmentsAndCommitMetadata; +import org.apache.druid.segment.realtime.appenderator.StreamAppenderatorDriver; +import org.apache.druid.segment.realtime.appenderator.TransactionalSegmentPublisher; +import org.apache.druid.timeline.DataSegment; +import org.apache.druid.timeline.partition.NumberedPartialShardSpec; +import org.apache.kafka.common.errors.WakeupException; + +import javax.annotation.Nullable; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; + +/** + * Single-threaded ingestion loop for {@link ShareGroupIndexTask}. Records are + * {@code ACCEPT}-acknowledged only after the containing segment is published; + * any earlier failure leaves them unacknowledged so the broker redelivers + * them once the acquisition lock expires. + */ +public class ShareGroupIndexTaskRunner +{ + private static final Logger log = new Logger(ShareGroupIndexTaskRunner.class); + + static final String METRIC_COMMIT_FAILURES = "ingest/shareGroup/commitFailures"; + + private final ShareGroupIndexTask task; + private final TaskToolbox toolbox; + private final ObjectMapper configMapper; + private final String sequenceName; + private final LockGranularity lockGranularity; + private final TaskLockType lockType; + private final Function<ShareGroupIndexTaskIOConfig, + AcknowledgingRecordSupplier<KafkaTopicPartition, Long, KafkaRecordEntity>> supplierFactory; + + private final AtomicReference<AcknowledgingRecordSupplier<KafkaTopicPartition, Long, KafkaRecordEntity>> + activeSupplier = new AtomicReference<>(); + + ShareGroupIndexTaskRunner( + ShareGroupIndexTask task, + TaskToolbox toolbox, + ObjectMapper configMapper + ) + { + this(task, toolbox, configMapper, null); + } + + @VisibleForTesting + ShareGroupIndexTaskRunner( + ShareGroupIndexTask task, + TaskToolbox toolbox, + ObjectMapper configMapper, + @Nullable Function<ShareGroupIndexTaskIOConfig, + AcknowledgingRecordSupplier<KafkaTopicPartition, Long, KafkaRecordEntity>> supplierFactory + ) + { + this.task = task; + this.toolbox = toolbox; + this.configMapper = configMapper; + this.sequenceName = task.getId(); + final boolean useTimeChunkLocks = Configs.valueOrDefault( + task.getContextValue(Tasks.FORCE_TIME_CHUNK_LOCK_KEY, Tasks.DEFAULT_FORCE_TIME_CHUNK_LOCK), + Tasks.DEFAULT_FORCE_TIME_CHUNK_LOCK + ); + this.lockGranularity = useTimeChunkLocks ? LockGranularity.TIME_CHUNK : LockGranularity.SEGMENT; + this.lockType = TaskLocks.determineLockTypeForAppend(task.getContext()); + this.supplierFactory = supplierFactory != null ? supplierFactory : this::createDefaultRecordSupplier; + } + + public TaskStatus run() throws Exception + { + final DataSchema dataSchema = task.getDataSchema(); + final KafkaIndexTaskTuningConfig tuningConfig = task.getTuningConfig(); + final ShareGroupIndexTaskIOConfig ioConfig = task.getIOConfig(); + + final RowIngestionMeters rowIngestionMeters = + toolbox.getRowIngestionMetersFactory().createRowIngestionMeters(); + final ParseExceptionHandler parseExceptionHandler = new ParseExceptionHandler( + rowIngestionMeters, + tuningConfig.isLogParseExceptions(), + tuningConfig.getMaxParseExceptions(), + tuningConfig.getMaxSavedParseExceptions() + ); + final SegmentGenerationMetrics segmentGenerationMetrics = new SegmentGenerationMetrics(); + + final InputRowSchema inputRowSchema = InputRowSchemas.fromDataSchema(dataSchema); + final InputFormat inputFormat = ioConfig.getInputFormat(); + if (inputFormat == null) { + throw new ISE("inputFormat must be specified in ioConfig"); + } + + // Raw type mirrors SeekableStreamIndexTaskRunner; works around + // OrderedPartitionableRecord.getData() returning a wildcard list. + @SuppressWarnings({"rawtypes", "unchecked"}) + final StreamChunkReader chunkReader = new StreamChunkReader<KafkaRecordEntity>( + inputFormat, + inputRowSchema, + dataSchema.getTransformSpec(), + toolbox.getIndexingTmpDir(), + InputRowFilter.allowAll(), + rowIngestionMeters, + parseExceptionHandler + ); + + final Appenderator appenderator = toolbox.getAppenderatorsManager().createRealtimeAppenderatorForTask( + toolbox.getSegmentLoaderConfig(), + task.getId(), + dataSchema, + SeekableStreamAppenderatorConfig.fromTuningConfig( + tuningConfig.withBasePersistDirectory(toolbox.getPersistDir()), + toolbox.getProcessingConfig() + ), + toolbox.getConfig(), + segmentGenerationMetrics, + toolbox.getSegmentPusher(), + toolbox.getJsonMapper(), + toolbox.getIndexIO(), + toolbox.getIndexMerger(), + toolbox.getQueryRunnerFactoryConglomerate(), + toolbox.getSegmentAnnouncer(), + toolbox.getEmitter(), + toolbox.getQueryProcessingPool(), + toolbox.getJoinableFactory(), + toolbox.getCache(), + toolbox.getCacheConfig(), + toolbox.getCachePopulatorStats(), + toolbox.getPolicyEnforcer(), + rowIngestionMeters, + parseExceptionHandler, + toolbox.getCentralizedTableSchemaConfig(), + interval -> { + toolbox.getTaskActionClient().submit(new LockReleaseAction(interval)); + } + ); + + final StreamAppenderatorDriver driver = new StreamAppenderatorDriver( + appenderator, + new ActionBasedSegmentAllocator( + toolbox.getTaskActionClient(), + dataSchema, + (schema, row, sequenceName, previousSegmentId, skipSegmentLineageCheck) -> new SegmentAllocateAction( + schema.getDataSource(), + row.getTimestamp(), + schema.getGranularitySpec().getQueryGranularity(), + schema.getGranularitySpec().getSegmentGranularity(), + sequenceName, + previousSegmentId, + skipSegmentLineageCheck, + NumberedPartialShardSpec.instance(), + lockGranularity, + lockType + ) + ), + toolbox.getSegmentHandoffNotifierFactory(), + new ActionBasedPublishedSegmentRetriever(toolbox.getTaskActionClient()), + toolbox.getDataSegmentKiller(), + toolbox.getJsonMapper(), + segmentGenerationMetrics + ); + + final org.apache.druid.indexing.common.stats.TaskRealtimeMetricsMonitor metricsMonitor = + new org.apache.druid.indexing.common.stats.TaskRealtimeMetricsMonitor( + segmentGenerationMetrics, + rowIngestionMeters, + task.getMetricBuilder() + ); + toolbox.addMonitor(metricsMonitor); + + boolean runLoopCompleted = false; + try (final AcknowledgingRecordSupplier<KafkaTopicPartition, Long, KafkaRecordEntity> recordSupplier = + supplierFactory.apply(ioConfig)) { + activeSupplier.set(recordSupplier); + final TaskStatus status = runLoop(driver, appenderator, recordSupplier, chunkReader, ioConfig, tuningConfig, toolbox); + runLoopCompleted = true; + return status; + } + finally { + activeSupplier.set(null); + if (!runLoopCompleted) { + try { + appenderator.closeNow(); + } + catch (Exception e) { + log.warn(e, "Exception during emergency closeNow() of Appenderator in run(); continuing teardown."); + } + } + try { + toolbox.removeMonitor(metricsMonitor); + } + catch (Exception e) { + log.warn(e, "Exception removing TaskRealtimeMetricsMonitor; continuing teardown."); + } + try { + driver.close(); + } + catch (Exception e) { + log.warn(e, "Exception closing StreamAppenderatorDriver; continuing teardown."); + } + } + } + + @VisibleForTesting + TaskStatus runLoop( + StreamAppenderatorDriver driver, + Appenderator appenderator, + AcknowledgingRecordSupplier<KafkaTopicPartition, Long, KafkaRecordEntity> recordSupplier, + StreamChunkReader chunkReader, + ShareGroupIndexTaskIOConfig ioConfig, + KafkaIndexTaskTuningConfig tuningConfig, + TaskToolbox toolbox + ) throws Exception + { + // Share groups manage delivery state on the broker; the Committer is a + // no-op placeholder required by the appenderator driver contract. + final Supplier<Committer> committerSupplier = () -> new Committer() + { + @Override + public Object getMetadata() + { + return ImmutableMap.of("type", "share_group"); + } + + @Override + public void run() + { + } + }; + + log.info("Starting share group ingestion loop for topic[%s], group[%s].", + ioConfig.getTopic(), ioConfig.getGroupId()); + + long totalRowsIngested = 0; + boolean lockTimeoutLogged = false; + boolean appenderatorClosedNormally = false; + + try { + recordSupplier.subscribe(Collections.singleton(ioConfig.getTopic())); + driver.startJob(this::acquireLockForRestoredSegment); + while (!task.isStopRequested()) { + + final List<OrderedPartitionableRecord<KafkaTopicPartition, Long, KafkaRecordEntity>> records; + try { + records = recordSupplier.poll(ioConfig.getPollTimeout()); + } + catch (WakeupException e) { + if (task.isStopRequested()) { + log.info("Wake-up received while stopGracefully is in progress; exiting ingestion loop."); + break; + } + throw e; + } + + if (!lockTimeoutLogged) { + final Optional<Integer> effectiveLockMs = recordSupplier.acquisitionLockTimeoutMs(); + log.info( + "Effective broker acquisition lock timeout for share-group[%s]: %s ms", + ioConfig.getGroupId(), + effectiveLockMs.map(String::valueOf).orElse("<unknown>") + ); + lockTimeoutLogged = true; + } + + if (records.isEmpty()) { + continue; + } + + final Map<KafkaTopicPartition, List<Long>> batchOffsets = new HashMap<>(); + boolean midBatchPersistNeeded = false; + boolean pushThresholdLogged = false; + + for (OrderedPartitionableRecord<KafkaTopicPartition, Long, KafkaRecordEntity> record : records) { + batchOffsets.computeIfAbsent(record.getPartitionId(), k -> new ArrayList<>()) + .add(record.getSequenceNumber()); + + final List<InputRow> rows = chunkReader.parse(record.getData(), false); + + for (InputRow row : rows) { + final AppenderatorDriverAddResult addResult = driver.add( + row, + sequenceName, + committerSupplier, + true, + false + ); + + if (!addResult.isOk()) { + // Throw without acknowledging so the broker redelivers this batch. + throw new ISE( + "Could not allocate segment for row with timestamp[%s]", + row.getTimestamp() + ); + } + + totalRowsIngested++; + midBatchPersistNeeded |= addResult.isPersistRequired(); + + if (!pushThresholdLogged && addResult.isPushRequired( + tuningConfig.getPartitionsSpec().getMaxRowsPerSegment(), + tuningConfig.getPartitionsSpec() + .getMaxTotalRowsOr(DynamicPartitionsSpec.DEFAULT_MAX_TOTAL_ROWS) + )) { + pushThresholdLogged = true; + log.info( + "Segment row threshold reached during batch (current segment[%s], rows[%d]); " + + "will publish at end-of-batch boundary.", + addResult.getSegmentIdentifier(), + addResult.getNumRowsInSegment() + ); + } + } + } + + if (midBatchPersistNeeded) { + driver.persist(committerSupplier.get()); + } + + driver.persist(committerSupplier.get()); + + final TransactionalSegmentPublisher publisher = new TransactionalSegmentPublisher() + { + @Override + public SegmentPublishResult publishAnnotatedSegments( + @Nullable Set<DataSegment> mustBeNullOrEmptyOverwriteSegments, + Set<DataSegment> segmentsToPush, + @Nullable Object commitMetadata, + SegmentSchemaMapping segmentSchemaMapping + ) throws IOException + { + if (segmentsToPush.isEmpty()) { + return SegmentPublishResult.ok(segmentsToPush); + } + return toolbox.getTaskActionClient().submit( + SegmentTransactionalAppendAction.forSegments(segmentsToPush, segmentSchemaMapping) Review Comment: [P2] Select the publisher for the configured lock type **Finding:** ShareGroupIndexTask accepts contexts with useConcurrentLocks=false or an explicit non-APPEND lock type, and the runner uses that type for allocation and restoration. This publisher always submits SegmentTransactionalAppendAction, whose perform method rejects every task lock that is not APPEND, so the first non-empty batch fails after persisting and its records remain unacknowledged for an otherwise accepted task configuration. **Suggestion:** Reject or normalize non-APPEND lock contexts for this task, or select the publish action from the effective lock type as the existing batch-task path does; add coverage for useConcurrentLocks=false. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
