perf: Improve efficiency of embedded kill tasks - #19772
Conversation
cryptoe
left a comment
There was a problem hiding this comment.
+1
I think the changes make sense to me.
Thank you for working on this.
| // Identify intervals with unused segments which are eligible for kill | ||
| final Map<DatasourceInterval, Integer> killCandidates = | ||
| storageCoordinator.retrieveSomeUnusedSegmentIntervals( | ||
| DateTimes.nowUtc().minus(killConfig.getBufferPeriod()), |
There was a problem hiding this comment.
So we have basically pushed in the bufferPeriod inside the sql query so that we donot populate segments which would be ineligible.
I think this change alone would get us lot of improvements.
I am a bit worried about fetching all the data sources at once but I guess there is where the inner query limit comes into place.
There was a problem hiding this comment.
Yes, the inner limit (currently 200k) takes care of that.
Fetching all the datasources in one go is actually better since in the current flow, we would fire separate queries for each datasource. When the number of datasources is large, this can be pretty redundant, especially since a lot of the datasources might not even have killable unused segments.
| @Test | ||
| public void test_maxSegmentsKilledInAnInterval_is_1k() | ||
| @Test(timeout = 30_000L) | ||
| public void test_maxSegmentsKilledInRun_is_200k() |
There was a problem hiding this comment.
On my machine this test legitimately takes nearly 30s to run, which has two problems:
- The
timeout = 30_000Lis low enough that it would make the test flaky - 30s is just generally a long time.
Is it possible to change the test to run faster while still being effective?
There was a problem hiding this comment.
Added a config to control the maxSegmentsToKill.
This allows us to use lower values for tests and for any cluster where a higher/lower value might be preferable.
Although, most clusters should work fine with the default value of 200k itself.
FrankChen021
left a comment
There was a problem hiding this comment.
| Severity | Findings |
|---|---|
| P0 | 0 |
| P1 | 2 |
| P2 | 1 |
| P3 | 0 |
| Total | 3 |
The capped global scan can starve cleanup behind locked intervals, embedded tasks can exceed their discovered deletion limits, and malformed interval rows can abort queue rebuilding.
Reviewed 8 of 8 changed files.
This is an automated review by Codex GPT-5.6-Sol
| //CHECKSTYLE.ON: Regexp | ||
| dbTables.getSegmentsTable(), | ||
| connector.getQuoteString(), | ||
| connector.limitClause(maxSegmentsToScan), |
There was a problem hiding this comment.
[P1] Prevent locked rows from monopolizing the global scan
The new global LIMIT has no ordering, cursor, or rotation state. If the selected 200,000 rows belong to intervals whose exclusive locks cannot be acquired, all generated tasks are skipped, the rows remain, and the next rebuild can select the identical slice again. Eligible segments outside that slice—including unrelated datasources—can then be starved indefinitely, whereas the previous per-datasource discovery still allowed other datasources to progress. Candidate discovery needs a deterministic progress/fairness mechanism that can advance past repeatedly skipped intervals.
There was a problem hiding this comment.
While this is possible, it is not very likely to happen.
- Firstly, intervals will not remain locked forever, unless there is a lot of late arriving data in streaming supervisors.
- Secondly, the search query need not pick up the same intervals in the next iteration.
Also, the eventual goal is to use concurrent locks in embedded kill tasks, thereby removing the need to skip locked intervals altogether. I will create a follow up PR for that change.
There was a problem hiding this comment.
Thanks for the context. After rechecking all 10 of 10 changed files, I would retain this finding. With no ordering, cursor, or rotation state, a stable query plan can return the same physical prefix on each rebuild. The locks need not be literally permanent: ongoing late-data activity can keep a prefix of streaming intervals locked while every row outside the global cap, including unrelated datasources, remains undiscovered. Since concurrent locking is deferred, this PR still needs deterministic progress or fairness.
cryptoe
left a comment
There was a problem hiding this comment.
LGTM post the doc changes for the new metric ELIGIBLE_UNUSED_SEGMENTS
| * The default for embedded kill tasks may differ from the cluster-level default | ||
| * {@link Tasks#DEFAULT_USE_CONCURRENT_LOCKS}. | ||
| */ | ||
| private static final boolean DEFAULT_USE_CONCURRENT_LOCKS = true; |
There was a problem hiding this comment.
Maybe add a comment here why this is safe for future readers.
There was a problem hiding this comment.
Done. Added info at the class-level javadoc and linked to it from the constant javadoc.
|
Thanks for the reviews, @cryptoe ! I have added docs for the new metric. |
FrankChen021
left a comment
There was a problem hiding this comment.
| Severity | Findings |
|---|---|
| P0 | 0 |
| P1 | 1 |
| P2 | 1 |
| P3 | 0 |
| Total | 2 |
Reviewed 22 of 22 changed files.
Validation: git diff --check passed.
This is an automated review by Codex GPT-5.6-Sol
| null, | ||
| Map.of( | ||
| Tasks.PRIORITY_KEY, Tasks.DEFAULT_EMBEDDED_KILL_TASK_PRIORITY, | ||
| Tasks.USE_CONCURRENT_LOCKS, useConcurrentLocks |
There was a problem hiding this comment.
[P1] REPLACE locks do not resolve capped-scan starvation
REPLACE only coexists with APPEND. Default ingestion still uses EXCLUSIVE locks, and SHARED or REPLACE jobs also remain incompatible. Those intervals are skipped and selected again on every capped scan, indefinitely starving eligible intervals beyond the cap. The prior head-of-line finding remains unresolved unless queue rebuilding advances past skipped candidates.
There was a problem hiding this comment.
This is okay, it is a nudge towards using concurrent locks.
There was a problem hiding this comment.
I don't think REPLACE alone closes this issue. Ingestion defaults to useConcurrentLocks=false (EXCLUSIVE), and REPLACE remains incompatible with EXCLUSIVE, SHARED, and REPLACE locks. Since skipped candidates are discarded and the capped SQL subquery has neither ordering nor a cursor, the same long-lived locked rows can be returned on every rebuild and keep rows beyond maxSegmentsToKill from ever entering the queue. Please make the scan advance past skipped candidates or otherwise guarantee progress.
Reviewed 29 of 29 changed files.
| private static final String TASK_ID_PREFIX = "overlord-issued"; | ||
|
|
||
| /** | ||
| * Use concurrent locks by default. |
There was a problem hiding this comment.
In delete.md there is this text added in #19737:
When using concurrent locks to run a
killtask, 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.
There is a similar note in the javadoc for KillUnusedSegmentsTask. Why don't we need to worry about that here? Can the rationale be added here, and possibly referenced in the other places?
There was a problem hiding this comment.
The older comment added in #19737 was somewhat incorrect.
The only case where a kill task could cause a potential data loss was if something like this happened:
- kill task starts and identifies the unused segments to kill
- markAsUsed API marks some of the target unused segments as used
- a concurrent APPEND job upgrades some of these segments
- kill task still thinks that the segments are unused and hard deletes their metadata entry as well as deep storage files
This patch originally included a javadoc comment explaining the above.
But then I decided that we might as well address the underlying issue, i.e. make the markAsUsed API mutually exclusive with any indexing/kill task. The docs already mention that calling these APIs while a task is in progress may lead to erratic behaviour.
Please let me know what you think.
FrankChen021
left a comment
There was a problem hiding this comment.
| Severity | Findings |
|---|---|
| P0 | 0 |
| P1 | 0 |
| P2 | 1 |
| P3 | 0 |
| Total | 1 |
Reviewed 29 of 29 changed files.
This is an automated review by Codex GPT-5.6-Sol
| { | ||
| public ExclusiveIntervalDummyTask(String id, String dataSource, Interval interval) | ||
| { | ||
| super(id, dataSource, interval, Map.of()); |
There was a problem hiding this comment.
[P2] Prevent the lock probe from revoking active tasks
ExclusiveIntervalDummyTask has an empty context, so it requests its EXCLUSIVE lock at priority 0. TaskLockbox.tryLock revokes incompatible locks below the requester's priority before granting the lock. A mark-used API call can therefore abort a negative-priority ingestion task and succeed instead of returning the documented conflict response whenever another task is active. Use minimum priority or a non-preempting acquisition path so any existing incompatible lock makes this probe fail.
There was a problem hiding this comment.
Default task priority is 0, so the markUsed API will get 0 priority.
Batch, realtime, and compact tasks have higher default priorities.
There was a problem hiding this comment.
Agreed that the built-in ingestion defaults are above 0. However, task priority is explicitly user-overridable through the task context, and task submission does not enforce a lower bound. An ingestion task configured with "priority": -1 therefore holds a lock below this dummy task's priority 0, causing revokeAllIncompatibleActiveLocksIfPossible to revoke it. Since this API is intended to return a conflict whenever another task holds an incompatible lock, the probe still needs a minimum priority or a non-preempting acquisition path.
Reviewed 3 of 29 changed files.
gianm
left a comment
There was a problem hiding this comment.
The change to the metadata query looks good to me. The locking changes, I'm not as sure about. I left a few comments.
Is it possible to split the PRs out, so they can be reviewed independently? I don't believe they necessarily need to be connected, and the metadata query changes should be good to merge more or less as-is.
| - Coordinator APIs for data management are now deprecated. Use new APIs served by the Overlord instead. | ||
| - Do not use these APIs while an indexing task or kill task is in progress for the same datasource and interval. | ||
| - The APIs to mark segments as used fail if an indexing task or kill task is in progress for the same datasource and overlapping interval, to ensure that there are no accidental data losses or data inconsistencies. | ||
| - Do not use the APIs to mark segments as unused while an indexing task or kill task is in progress for the same datasource and interval. |
There was a problem hiding this comment.
Why the asymmetry? How are people supposed to know if an indexing or kill task is in progress? (It isn't obvious what the interval is without looking at the specs, which is tedious if a lot of tasks are running.)
There was a problem hiding this comment.
Yes, I will try to include the info of the locked interval in the exception that gets thrown.
I will create a separate patch for the locking changes to markUsed as well as markUnused APIs.
The markUnused would be trickier as it is frequently used by the Coordinator to delete overshadowed segments. Acquiring an EXCLUSIVE lock while the Coordinator does this is not desirable.
There was a problem hiding this comment.
locking changes removed from this PR.
| private final Map<String, Object> context; | ||
|
|
||
| @JsonCreator | ||
| public DefaultTaskConfig( |
There was a problem hiding this comment.
Why this change? It seems like the end result would be similar.
There was a problem hiding this comment.
Having the constructor simplifies instantiating this config in tests.
| * map is not empty. However, it does NOT guarantee that: | ||
| * <ul> | ||
| * <li>the candidates in the returned map would be ordered by datasource or interval</li> | ||
| * <li>the result would contain {@code limit} entries when there are more distinct |
There was a problem hiding this comment.
limit is not a parameter. Is this meant to refer to maxResultSize?
| } | ||
|
|
||
| /** | ||
| * Scans upto {@code maxSegmentsToScan} unused segments which are eligible for |
There was a problem hiding this comment.
up to (spelling)
Also, this seems to be a copy of the javadoc for IndexerMetadataStorageCoordinator#retrieveSomeUnusedSegmentIntervals. Can one reference the other rather than copying the text?
|
|
||
| try { | ||
| // Acquire lock on the interval before performing the update operation | ||
| taskLockbox.add(dummyTask); |
There was a problem hiding this comment.
It seems sketchy to insert a dummy task into the TaskLockbox. Certain operations in the lockbox require the task to exist in storage, such as revokeLock, which I think might get called if a higher-priority task is launched for the same interval.
Have you traced through the possibilities and determined this dummy-task approach to be OK?
There was a problem hiding this comment.
Thanks for calling this out, let me evaluate this further and add some validations.
If it still doesn't seem viable, I will try an alternative.
(I suppose we could always remove that check from TaskLockbox, as the Task object fetched from storage is not really used for anything else).
There was a problem hiding this comment.
locking changes removed from this PR.
| */ | ||
| public int markAllNonOvershadowedSegmentsAsUsed(String dataSource) | ||
| { | ||
| return markAsUsedWithExclusiveLock( |
There was a problem hiding this comment.
Given this requires an exclusive lock on eternity, will it be able to run on a datasource that is receiving active ingestion? I would think that it would require pausing ingestion? Seems like it would make it difficult to use.
There was a problem hiding this comment.
Yes, that's true.
I suppose we could first identify the intervals that have eligible non-overshadowed segments that may be marked as used, and only try to lock those intervals.
There was a problem hiding this comment.
locking changes removed from this PR.
| public TaskLockType determineLockType() | ||
| { | ||
| TaskLockType actualLockType; | ||
| final boolean useConcurrentLocks = Boolean.TRUE.equals( |
There was a problem hiding this comment.
This should coerce in the manner of QueryContexts#getAsBoolean, so "true" (the string) is treated as true too.
There was a problem hiding this comment.
fixed, using the new utility method in TaskLocks.
| return TaskLockType.REPLACE; | ||
| } else { | ||
| actualLockType = getContextValue(Tasks.TASK_LOCK_TYPE, TaskLockType.EXCLUSIVE); | ||
| return getContextValue(Tasks.TASK_LOCK_TYPE, TaskLockType.EXCLUSIVE); |
There was a problem hiding this comment.
This should coerce too. As written I believe it will throw CastClassException on real JSON, since it'll try to cast the String values to TaskLockType.
|
Thanks for the feedback, @gianm . I have removed the locking changes from the PR for now. |
| @Inject | ||
| public UnusedSegmentsKiller( | ||
| SegmentsMetadataManagerConfig config, | ||
| DefaultTaskConfig defaultTaskConfig, |
FrankChen021
left a comment
There was a problem hiding this comment.
| Severity | Findings |
|---|---|
| P0 | 0 |
| P1 | 2 |
| P2 | 0 |
| P3 | 0 |
| Total | 2 |
Reviewed 15 of 15 changed files. The dummy lock-probe and malformed-interval findings are resolved; capped-scan starvation remains in its existing thread, the shared-load-spec limit remains, and the update adds an explicit-lock-type regression.
This is an automated review by Codex GPT-5.6-Sol
| null, | ||
| null, | ||
| MAX_SEGMENTS_TO_KILL_IN_BATCH, | ||
| candidate.numSegmentsToKill(), |
There was a problem hiding this comment.
[P1] Count metadata deletions against the candidate limit
candidate.numSegmentsToKill() becomes this task's limit, but KillUnusedSegmentsTask reduces the remaining limit only by segments deleted from deep storage even though SegmentNukeAction removes the entire fetched batch from metadata. For a 1,001-row candidate whose load specs are shared, the first batch can nuke 1,000 metadata rows while counting zero progress, and the second batch can nuke another 1,000. The duty can therefore exceed maxSegmentsToKill; track metadata rows processed for this limit or cap the embedded fetches independently.
| ); | ||
|
|
||
| TaskLockType actualLockType = determineLockType(useConcurrentLocks); | ||
| final TaskLockType actualLockType = useConcurrentLocks ? TaskLockType.REPLACE : TaskLockType.EXCLUSIVE; |
There was a problem hiding this comment.
[P1] Preserve the explicit task lock type
This reduces every non-REPLACE context to EXCLUSIVE. For example, taskLockType: APPEND is parsed successfully by shouldUseConcurrentLocksForReplace but returns false here, so the task requests EXCLUSIVE instead of the explicitly requested APPEND lock. A programmatic TaskLockType.APPEND value also throws because QueryContexts.getAsEnum does not accept enum objects. This deterministically breaks KillUnusedSegmentsTaskTest#testIsReadyWithContextAppendLock; retain the parsed lock type when useConcurrentLocks does not select REPLACE.
gianm
left a comment
There was a problem hiding this comment.
This PR, now limited to the perf improvement, looks good to me. I believe the safety part would be split out into a separate PR, so consider removing the "make concurrent kill safer" text from the PR title. When the other PR is available please link it here too.
Description
The
UnusedSegmentsKillercurrenly has some limitations:UnusedSegmentsKillerwould take very long to clear a backlog of killable unused segments.Changes
UnusedSegmentsKillerand small enough to keep the query time within ~5s (tried on MySQL 8 with 17M unused segments).KillCandidate.This PR has: