perf: speed-up coordinator segment metadata cache delta syncs#19672
perf: speed-up coordinator segment metadata cache delta syncs#19672jtuglu1 wants to merge 2 commits into
Conversation
26405b1 to
984d09e
Compare
FrankChen021
left a comment
There was a problem hiding this comment.
| Severity | Findings |
|---|---|
| P0 | 0 |
| P1 | 1 |
| P2 | 1 |
| P3 | 0 |
| Total | 2 |
| Severity | Findings |
|---|---|
| P0 | 0 |
| P1 | 1 |
| P2 | 1 |
| P3 | 0 |
| Total | 2 |
Reviewed 7 of 7 changed files.
This is an automated review by Codex GPT-5.6-Sol
| datasourceToUsedSegments.put(dataSource, cache.findUsedSegmentsOverlappingAnyOf(List.of())); | ||
| // Only materialize the (potentially large) used-segment set for datasources | ||
| // that must be rebuilt into the snapshot. | ||
| if (!incremental || dirtyDatasources.contains(dataSource)) { |
There was a problem hiding this comment.
[P1] Track write-through cache mutations as dirty
Incremental rebuilding only materializes datasources returned by updateSegmentIdsInCache. However, CachedSegmentMetadataTransaction applies successful inserts and deletes directly to the datasource cache. On the next poll, the database and cache already match, so syncSegmentIds reports no expired or deleted IDs and the datasource is skipped here. Consequently, newly inserted segments can remain absent from the published snapshot, and partial deletions can remain present, indefinitely until an unrelated update dirties that datasource. Record used-segment mutations from writeCacheForDataSource or otherwise compare against the prior snapshot before reusing it.
There was a problem hiding this comment.
Nice catch! This does seem to be an issue that will cause the snapshot and the cache for a datasource to diverge. Normally, the snapshot reconciles itself with what's in the cache in the next sync.
But we are removing that capability in this PR by updating snapshots for modified datasources only.
Record used-segment mutations from writeCacheForDataSource or otherwise compare against the prior snapshot before reusing it.
Comparing against prior snapshot might be too costly, I think we could do the following instead.
ReadWriteCachekeeps a volatile booleanhasNewWrites.- Set this flag to true in
withWriteLock(). - Also, add method
ReadWriteCache.markAllWritesAsSynced() - Add new methods to
HeapMemoryDatasourceSegmentCache:getAllUsedSegmentsIfCacheHasNewWrites(). - Invoke this method in incremental mode only
- This method should acquire a write lock, return all the used segments only if there are writes and finally set
hasNewWritesto false.
| createIndex( | ||
| tableName, | ||
| "IDX_%S_USED_USLU_DATASOURCE", | ||
| List.of("used", "used_status_last_updated", "dataSource") |
There was a problem hiding this comment.
[P2] Include id in the portable covering index
The assumption that a secondary index implicitly carries the primary key is specific to engines such as InnoDB. PostgreSQL stores the primary key in a separate index, so this index still requires a heap lookup for every selected id, defeating the intended index-only scan on a supported metadata backend. Include id explicitly or provide backend-specific index definitions.
There was a problem hiding this comment.
Yeah, I have my doubts about this too.
But it is okay since after this, we can get rid of the older IDX_USED index.
@jtuglu1 , should we do it in this PR itself? It will affect only new clusters anyway.
| public static DataSourcesSnapshot withUpdatedDataSources( | ||
| @Nullable DataSourcesSnapshot previous, |
There was a problem hiding this comment.
For a cleaner API, let's not use a static method which allows passing a null previous snapshot.
Instead, add a non-static method updateSnapshotForDatasources() which will be called on the existing snapshot by the caller itself.
newSnapshot = previousSnapshot.updateSnapshotForDatasources(changed, removed, snapshotTime);| final Map<String, ImmutableDruidDataSource> dataSources = new HashMap<>(previous.dataSourcesWithAllUsedSegments); | ||
| final Map<String, SegmentTimeline> timelines = new HashMap<>(previous.usedSegmentsTimelinesPerDataSource); | ||
|
|
||
| final Set<String> dirtyDataSources = new HashSet<>(removedDataSources); |
There was a problem hiding this comment.
Please use some other term here like modified or datasourcesToRefresh, etc.
| timelines.remove(ds); | ||
| }); | ||
| changedDataSources.forEach((ds, segments) -> { | ||
| if (segments.isEmpty()) { |
There was a problem hiding this comment.
Wouldn't this case already be present in removedDatasources? Is this just a safe-side measure?
| * {@link DataSourcesSnapshot} instead of the whole snapshot. Drift-free: the | ||
| * set read from the metadata store is unchanged (still the full used set). | ||
| */ | ||
| private final boolean useIncrementalSync; |
There was a problem hiding this comment.
I don't think this needs to be optional since it is a perf improvement that all clusters can benefit from.
Are there any drawbacks to the approach?
There was a problem hiding this comment.
There shouldn't be, I'll remove.
| @JsonProperty("useIncrementalCache") SegmentMetadataCache.UsageMode useIncrementalCache, | ||
| @JsonProperty("killUnused") UnusedSegmentKillerConfig killUnused | ||
| @JsonProperty("killUnused") UnusedSegmentKillerConfig killUnused, | ||
| @JsonProperty("useIncrementalSync") Boolean useIncrementalSync |
There was a problem hiding this comment.
We can skip this config and have this feature always on.
|
|
||
| // Datasources whose used-segment set changed this sync. Null forces a full | ||
| // snapshot rebuild (first sync, or when incremental sync is disabled). | ||
| Set<String> dirtyDatasources = null; |
There was a problem hiding this comment.
| Set<String> dirtyDatasources = null; | |
| Set<String> modifiedDatasources = null; |
| * <p> | ||
| * Overshadowing is computed per-datasource, so reusing the prior overshadowed | ||
| * segments for unchanged datasources is correct. This turns the O(all-segments) | ||
| * snapshot rebuild into an O(changed-segments) operation, while producing a |
There was a problem hiding this comment.
This is not exactly true since we would still be rebuilding the entire timeline of a datasource only if a single segment changed in that datasource. That said, it would still be better than what we have today.
| @@ -743,13 +788,17 @@ private void retrieveRequiredUsedSegments( | |||
| * datasource cache is atomic. Also identifies the segment IDs which have been | |||
| * updated in the metadata store and need to be refreshed in the cache. | |||
There was a problem hiding this comment.
| * updated in the metadata store and need to be refreshed in the cache. | |
| * updated in the metadata store and need to be refreshed in the cache. | |
| * | |
| * @return Set of datasource names whose used segment set has changed in this sync and need to be refreshed in the snapshot. | |
| */ |
|
|
||
| // Datasources whose used-segment set actually changed this sync (used to | ||
| // rebuild only the affected part of the snapshot). | ||
| final Set<String> dirtyDatasources = new HashSet<>(); |
There was a problem hiding this comment.
| final Set<String> dirtyDatasources = new HashSet<>(); | |
| final Set<String> datasourcesToRefresh = new HashSet<>(); |
| datasourceToUsedSegments.put(dataSource, cache.findUsedSegmentsOverlappingAnyOf(List.of())); | ||
| // Only materialize the (potentially large) used-segment set for datasources | ||
| // that must be rebuilt into the snapshot. | ||
| if (!incremental || dirtyDatasources.contains(dataSource)) { |
There was a problem hiding this comment.
Nice catch! This does seem to be an issue that will cause the snapshot and the cache for a datasource to diverge. Normally, the snapshot reconciles itself with what's in the cache in the next sync.
But we are removing that capability in this PR by updating snapshots for modified datasources only.
Record used-segment mutations from writeCacheForDataSource or otherwise compare against the prior snapshot before reusing it.
Comparing against prior snapshot might be too costly, I think we could do the following instead.
ReadWriteCachekeeps a volatile booleanhasNewWrites.- Set this flag to true in
withWriteLock(). - Also, add method
ReadWriteCache.markAllWritesAsSynced() - Add new methods to
HeapMemoryDatasourceSegmentCache:getAllUsedSegmentsIfCacheHasNewWrites(). - Invoke this method in incremental mode only
- This method should acquire a write lock, return all the used segments only if there are writes and finally set
hasNewWritesto false.
Description
Currently, coordinator segment metadata cache syncs (even deltas) are incredibly slow for large used segment sets. This increases the ingest-to-query latency of data as well as hindering coordinator recovery in the event of failover + leadership re-election.
Speed up coordinator delta syncs by rebuilding a delta snapshot of the changed datasources rather than the full thing. Currently, every poll it throws away the old snapshot and builds a brand new one for every datasource, walking all used segments to rebuild every timeline; it does this even when almost nothing changed since the last poll. For a cluster with 25M+ segments, this can take ~2mins of time.
This also adds a better-covering index. The segments table has an index on used, so the db can quickly find which rows are used. But that index does not contain dataSource or used_status_last_updated, so this scan is very slow for large #s of segments (4mins+). We should then delete the used-only index since it is fully covered by this one.
Release note
Speed-up coordinator metadata cache delta syncs
This PR has: