Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions docs/data-management/delete.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,13 @@ Some of the parameters used in the task payload are further explained below:
| `limit` | null (no limit) | Maximum number of segments for the kill task to delete.|
| `maxUsedStatusLastUpdatedTime` | null (no cutoff) | Maximum timestamp used as a cutoff to include unused segments. The kill task only considers segments which lie in the specified `interval` and were marked as unused no later than this time. The default behavior is to kill all unused segments in the `interval` regardless of when they where marked as unused.|


**WARNING:** The `kill` task permanently removes all information about the affected segments from the metadata store and
:::warning
- The `kill` task permanently removes all information about the affected segments from the metadata store and
deep storage. This operation cannot be undone.
- When using [concurrent locks](../ingestion/concurrent-append-replace.md) to run a `kill` task, ensure to keep a large
enough buffer period before killing segments after they have been marked as unused. Otherwise, there may be a potential
data loss if a concurrent append job upgrades one of the segments that are being killed.
:::

### Auto-kill data using Coordinator duties

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,10 @@
import org.apache.druid.java.util.common.StringUtils;
import org.apache.druid.java.util.common.logger.Logger;
import org.apache.druid.server.coordination.BroadcastDatasourceLoadingSpec;
import org.apache.druid.server.http.DataSegmentPlus;
import org.apache.druid.server.lookup.cache.LookupLoadingSpec;
import org.apache.druid.server.security.ResourceAction;
import org.apache.druid.timeline.DataSegment;
import org.apache.druid.timeline.SegmentId;
import org.apache.druid.utils.CollectionUtils;
import org.joda.time.DateTime;
import org.joda.time.Interval;
Expand All @@ -67,10 +67,10 @@
import java.util.NavigableMap;
import java.util.Set;
import java.util.TreeMap;
import java.util.function.Function;
import java.util.stream.Collectors;

/**
* <p/>
* The client representation of this task is {@link ClientKillUnusedSegmentsTaskQuery}.
* JSON serialization fields of this class must correspond to those of {@link
* ClientKillUnusedSegmentsTaskQuery}, except for {@link #id} and {@link #context} fields.
Expand All @@ -87,6 +87,10 @@
* <li> Filter the set of unreferenced segments using load specs from the set of used segments. </li>
* <li> Kill the filtered set of segments from deep storage. </li>
* </ol>
* Note: When {@link Tasks#USE_CONCURRENT_LOCKS} is true, keep a large buffer
* period before killing segments after they have been marked as unused.
* Otherwise, there may be a potential data loss if a concurrent APPEND job
Comment thread
kfaraz marked this conversation as resolved.
* upgrades one of the segments that are being killed.
*/
public class KillUnusedSegmentsTask extends AbstractFixedIntervalTask
{
Expand Down Expand Up @@ -211,7 +215,7 @@ public TaskStatus runTask(TaskToolbox toolbox) throws Exception
// List unused segments
int nextBatchSize = computeNextBatchSize(numSegmentsKilled);
@Nullable Integer numTotalBatches = getNumTotalBatches();
List<DataSegment> unusedSegments;
List<DataSegmentPlus> unusedSegmentsPlus;
logInfo(
"Starting kill for datasource[%s] in interval[%s] and versions[%s] with batchSize[%d], up to limit[%d]"
+ " segments before maxUsedStatusLastUpdatedTime[%s] will be deleted%s",
Expand All @@ -236,12 +240,25 @@ public TaskStatus runTask(TaskToolbox toolbox) throws Exception
break;
}

unusedSegments = fetchNextBatchOfUnusedSegments(toolbox, nextBatchSize);
unusedSegmentsPlus = fetchNextBatchOfUnusedSegments(toolbox, nextBatchSize);
if (unusedSegmentsPlus.isEmpty()) {
// No more segments eligible for kill, do not proceed further
break;
}

// Fetch locks each time as a revokal could have occurred in between batches
final NavigableMap<DateTime, List<TaskLock>> taskLockMap
= getNonRevokedTaskLockMap(toolbox.getTaskActionClient());

final Set<DataSegment> unusedSegments = unusedSegmentsPlus.stream()
.map(DataSegmentPlus::getDataSegment)
.collect(Collectors.toSet());
final Map<String, DataSegmentPlus> unusedIdToSegmentPlus = CollectionUtils.toMap(
unusedSegmentsPlus,
segment -> segment.getDataSegment().getId().toString(),
Function.identity()
);

if (!TaskLocks.isLockCoversSegments(taskLockMap, unusedSegments)) {
Comment on lines +253 to 262

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: perhaps we could introduce a thin wrapper for isLockCoversSegments() that also accepts DataSegmentPlus that delegates to isLockCoversSegments(NavigableMap<DateTime, List>, Collection segments) to avoid another iteration over the set of segments.

@kfaraz kfaraz Jul 25, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Set<DataSegment> unusedSegments created before this line is also used later in this method.

throw new ISE(
"Locks[%s] for task[%s] can't cover segments[%s]",
Expand All @@ -251,62 +268,46 @@ public TaskStatus runTask(TaskToolbox toolbox) throws Exception
);
}

// Kill segments. Order is important here:
// Retrieve the segment upgrade infos for the batch _before_ the segments are nuked
// We then want the nuke action to clean up the metadata records _before_ the segments are removed from storage.
// This helps maintain that we will always have a storage segment if the metadata segment is present.
// Determine the subset of segments to be killed from deep storage based on loadspecs.
// If the segment nuke throws an exception, then the segment cleanup is abandoned.

// Determine upgraded segment ids before nuking
final Set<String> segmentIds = unusedSegments.stream()
.map(DataSegment::getId)
.map(SegmentId::toString)
.collect(Collectors.toSet());
final Map<String, String> upgradedFromSegmentIds = new HashMap<>();
try {
upgradedFromSegmentIds.putAll(
taskActionClient.submit(
new RetrieveUpgradedFromSegmentIdsAction(getDataSource(), segmentIds)
).getUpgradedFromSegmentIds()
);
}
catch (Exception e) {
LOG.warn(
e,
"Could not retrieve parent segment ids using task action[retrieveUpgradedFromSegmentIds]."
+ " Overlord may be on an older version."
);
}
// Kill segments - order of steps 1, 2, 3, 4 must remain the same

// Nuke Segments
taskActionClient.submit(new SegmentNukeAction(new HashSet<>(unusedSegments)));
emitMetric(toolbox.getEmitter(), TaskMetrics.SEGMENTS_DELETED_FROM_METADATA_STORE, unusedSegments.size());
// 1. Determine parent segment ids of killable unused segments
final Map<String, String> upgradedFromSegmentIds
= fetchParentIdsForSegments(toolbox, unusedIdToSegmentPlus);

// Determine segments to be killed
final List<DataSegment> segmentsToBeKilled
= getKillableSegments(unusedSegments, upgradedFromSegmentIds, usedSegmentLoadSpecs, taskActionClient);
// 2. Identify killable segments whose load specs are not shared with any other segment
final List<DataSegment> segmentsToKillFromDeepStore = getKillableSegments(
unusedIdToSegmentPlus,
upgradedFromSegmentIds,
usedSegmentLoadSpecs,
taskActionClient
);

// 2a. Track segments that cannot be removed from deep store yet
final Set<DataSegment> segmentsNotKilled = new HashSet<>(unusedSegments);
segmentsToBeKilled.forEach(segmentsNotKilled::remove);

segmentsToKillFromDeepStore.forEach(segmentsNotKilled::remove);
if (!segmentsNotKilled.isEmpty()) {
LOG.warn(
"Skipping kill of [%d] segments from deep storage as their load specs are used by other segments.",
segmentsNotKilled.size()
"Skipping kill of [%d] segments of datasource[%s] from deep storage"
Comment thread
abhishekrb19 marked this conversation as resolved.
+ " as their load specs are shared by other segments.",
Comment thread
abhishekrb19 marked this conversation as resolved.
segmentsNotKilled.size(), getDataSource()
);
}

toolbox.getDataSegmentKiller().kill(segmentsToBeKilled);
emitMetric(toolbox.getEmitter(), TaskMetrics.SEGMENTS_DELETED_FROM_DEEPSTORE, segmentsToBeKilled.size());
// 3. Nuke all eligible unused segments

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Close the sharing race before deleting deep storage

The new relationship lookup is only a preflight before SegmentNukeAction. With concurrent locks enabled this task holds a REPLACE lock, which can coexist with APPEND publication; an append publish or retry can therefore insert a parent or upgraded sibling sharing the load spec after step 2. Step 3 then removes the old metadata and step 4 deletes a file still referenced by the newly published segment. Keep the preflight, but perform a fail-closed sharing recheck after the nuke and before deep-storage deletion (ideally in a transaction/critical section that excludes a new publication window).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is very unlikely for this to happen. It had been called out here as well.
#16362 (comment)

Essentially, the kill buffer period protects us against this.
Concurrent APPEND would only perform upgrades on a "used" segment.
And kill task would only nuke segments that have been been "unused" for a while (default is 30 days).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding a comment in javadoc.

taskActionClient.submit(new SegmentNukeAction(unusedSegments));
emitMetric(toolbox.getEmitter(), TaskMetrics.SEGMENTS_DELETED_FROM_METADATA_STORE, unusedIdToSegmentPlus.size());

// 4. Delete deep store files only for segments which do not share load specs with other segments
toolbox.getDataSegmentKiller().kill(segmentsToKillFromDeepStore);
emitMetric(toolbox.getEmitter(), TaskMetrics.SEGMENTS_DELETED_FROM_DEEPSTORE, segmentsToKillFromDeepStore.size());

numBatchesProcessed++;
numSegmentsKilled += segmentsToBeKilled.size();
numSegmentsKilled += segmentsToKillFromDeepStore.size();

logInfo("Processed [%d] batches for kill task[%s].", numBatchesProcessed, getId());

nextBatchSize = computeNextBatchSize(numSegmentsKilled);
} while (!unusedSegments.isEmpty() && (null == numTotalBatches || numBatchesProcessed < numTotalBatches));
} while (!unusedSegmentsPlus.isEmpty() && (null == numTotalBatches || numBatchesProcessed < numTotalBatches));

final String taskId = getId();
logInfo(
Expand Down Expand Up @@ -342,7 +343,7 @@ int computeNextBatchSize(int numSegmentsKilled)
/**
* Fetches the next batch of unused segments that are eligible for kill.
*/
protected List<DataSegment> fetchNextBatchOfUnusedSegments(TaskToolbox toolbox, int nextBatchSize) throws IOException
protected List<DataSegmentPlus> fetchNextBatchOfUnusedSegments(TaskToolbox toolbox, int nextBatchSize) throws IOException
{
return toolbox.getTaskActionClient().submit(
new RetrieveUnusedSegmentsAction(
Expand All @@ -352,7 +353,44 @@ protected List<DataSegment> fetchNextBatchOfUnusedSegments(TaskToolbox toolbox,
nextBatchSize,
maxUsedStatusLastUpdatedTime
)
);
)
.stream()
.map(segment -> new DataSegmentPlus(segment, null, null, null, null, null, null, null))
.collect(Collectors.toList());
}

/**
* Fetches the parent IDs (if any) for the given unused segments.
*
* @param unusedIdToSegmentPlus Map containing unused segments whose parent IDs
* need to be fetched
* @return Map from segment ID to the segment ID from which
* it was upgraded. If an input segment was not upgraded from any other segment,
* it does not have an entry in the map.
*/
protected Map<String, String> fetchParentIdsForSegments(
TaskToolbox toolbox,
Map<String, DataSegmentPlus> unusedIdToSegmentPlus
)
{
try {
return toolbox.getTaskActionClient().submit(
new RetrieveUpgradedFromSegmentIdsAction(getDataSource(), unusedIdToSegmentPlus.keySet())
).getUpgradedFromSegmentIds();
}
catch (Exception e) {
// Do not proceed with killing these segments as we cannot be sure if their
// load spec is shared by any other segment or not. If load spec is shared,
// segment files cannot be deleted from deep store. If load spec is not
// shared, segments cannot be deleted from metadata store as that would
// leave deep store files orphaned, and they would never be cleaned up.
throw new ISE(
e,
"Could not retrieve parent segment ids using task action[retrieveUpgradedFromSegmentIds]."
+ " Stopping kill task to avoid data loss in case the segment files"
+ " are shared by other segments."
Comment on lines +388 to +391

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about adding a unit test in KillUnusedSegmentsTaskTest or UnusedSegmentsKillerTest to verify that segments aren't spuriously purged in this case?

@kfaraz kfaraz Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion! Added some tests.

);
}
}

/**
Expand Down Expand Up @@ -387,21 +425,20 @@ private NavigableMap<DateTime, List<TaskLock>> getNonRevokedTaskLockMap(TaskActi
* @return list of segments to kill from deep storage
*/
private List<DataSegment> getKillableSegments(
List<DataSegment> unusedSegments,
Map<String, DataSegmentPlus> unusedSegments,
Map<String, String> upgradedFromSegmentIds,
Set<Map<String, Object>> usedSegmentLoadSpecs,
TaskActionClient taskActionClient
)
{

// Determine parentId for each unused segment
// Determine parentId (or self, if no parent) for each unused segment
final Map<String, Set<DataSegment>> parentIdToUnusedSegments = new HashMap<>();
for (DataSegment segment : unusedSegments) {
final String segmentId = segment.getId().toString();
for (Map.Entry<String, DataSegmentPlus> entry : unusedSegments.entrySet()) {
final String segmentId = entry.getKey();
parentIdToUnusedSegments.computeIfAbsent(
upgradedFromSegmentIds.getOrDefault(segmentId, segmentId),
k -> new HashSet<>()
).add(segment);
).add(entry.getValue().getDataSegment());
}

// Check if the parent or any of its children exist in metadata store
Expand All @@ -411,10 +448,11 @@ private List<DataSegment> getKillableSegments(
);
if (response != null && response.getUpgradedToSegmentIds() != null) {
response.getUpgradedToSegmentIds().forEach((parent, children) -> {
if (!CollectionUtils.isNullOrEmpty(children)) {
// Do not kill segment if its parent or any of its siblings still exist in metadata store
if (!unusedSegments.keySet().containsAll(children)) {
// Do not kill segment if its load spec is shared by another segment
// which is not being killed.
LOG.info(
"Skipping kill of segments[%s] as its load spec is also used by segment IDs[%s].",
"Skipping kill of segments[%s] as its load spec is shared by segment IDs[%s].",
parentIdToUnusedSegments.get(parent), children
);
parentIdToUnusedSegments.remove(parent);
Expand All @@ -423,10 +461,14 @@ private List<DataSegment> getKillableSegments(
}
}
catch (Exception e) {
LOG.warn(
// Do not proceed with the kill of any segment as we cannot be sure if their
// load specs are shared by any other segment
throw new ISE(
e,
"Could not retrieve referenced ids using task action[retrieveUpgradedToSegmentIds]."
+ " Overlord may be on an older version."
"Could not perform task action[retrieveUpgradedToSegmentIds] to retrieve"
+ " segment IDs which share load specs with segments being killed."
+ " Stopping kill task to avoid data loss in case the segment files"
+ " are shared by other segments."
);
}

Expand All @@ -449,7 +491,7 @@ private boolean isSegmentLoadSpecPresentIn(
{
boolean isPresent = usedSegmentLoadSpecs.contains(segment.getLoadSpec());
if (isPresent) {
LOG.info("Skipping kill of segment[%s] as its load spec is also used by other segments.", segment);
LOG.info("Skipping kill of segment[%s] as its load spec is shared by other 'used' segments.", segment);
}
return isPresent;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
import org.apache.druid.metadata.UnusedSegmentKillerConfig;
import org.apache.druid.query.DruidMetrics;
import org.apache.druid.segment.loading.DataSegmentKiller;
import org.apache.druid.timeline.DataSegment;
import org.apache.druid.server.http.DataSegmentPlus;
import org.joda.time.DateTime;
import org.joda.time.Duration;
import org.joda.time.Interval;
Expand Down Expand Up @@ -455,7 +455,7 @@ protected Integer getNumTotalBatches()
}

@Override
protected List<DataSegment> fetchNextBatchOfUnusedSegments(TaskToolbox toolbox, int nextBatchSize)
protected List<DataSegmentPlus> fetchNextBatchOfUnusedSegments(TaskToolbox toolbox, int nextBatchSize)
{
// Kill only 1000 segments in the batch so that locks are not held for very long
return storageCoordinator.retrieveUnusedSegmentsWithExactInterval(
Expand All @@ -466,6 +466,27 @@ protected List<DataSegment> fetchNextBatchOfUnusedSegments(TaskToolbox toolbox,
);
}

@Override
protected Map<String, String> fetchParentIdsForSegments(
TaskToolbox toolbox,
Map<String, DataSegmentPlus> unusedIdToSegmentPlus
)
{
// No need to make another DB call, the parent IDs have already been fetched
// in fetchNextBatchOfUnusedSegments
final Map<String, String> unusedSegmentIdToParentId = new HashMap<>();
for (DataSegmentPlus segment : unusedIdToSegmentPlus.values()) {
if (segment.getUpgradedFromSegmentId() != null) {
unusedSegmentIdToParentId.put(
segment.getDataSegment().getId().toString(),
segment.getUpgradedFromSegmentId()
);
}
}

return unusedSegmentIdToParentId;
}

@Override
protected void logInfo(String message, Object... args)
{
Expand Down
Loading
Loading