@@ -41,6 +41,7 @@ import org.apache.kafka.common.utils.Time
41
41
import scala .collection .{Map , Seq , Set , mutable }
42
42
import scala .jdk .CollectionConverters ._
43
43
import scala .jdk .OptionConverters .RichOptional
44
+ import scala .util .control .Breaks ._
44
45
45
46
trait AutoTopicCreationManager {
46
47
@@ -71,6 +72,7 @@ case class CachedTopicCreationError(
71
72
val timestamp : Long = time.milliseconds()
72
73
}
73
74
75
+
74
76
class DefaultAutoTopicCreationManager (
75
77
config : KafkaConfig ,
76
78
channelManager : NodeToControllerChannelManager ,
@@ -81,7 +83,20 @@ class DefaultAutoTopicCreationManager(
81
83
) extends AutoTopicCreationManager with Logging {
82
84
83
85
private val inflightTopics = Collections .newSetFromMap(new ConcurrentHashMap [String , java.lang.Boolean ]())
84
- private val topicCreationErrorCache = new ConcurrentHashMap [String , CachedTopicCreationError ]()
86
+
87
+ // Use MAX_INCREMENTAL_FETCH_SESSION_CACHE_SLOTS_CONFIG as the size limit for the error cache
88
+ // This provides a reasonable bound (default 1000) to prevent unbounded growth
89
+ private val maxCacheSize = config.maxIncrementalFetchSessionCacheSlots
90
+ info(s " AutoTopicCreationManager initialized with error cache size limit: $maxCacheSize" )
91
+
92
+ // LRU cache with size limit to prevent unbounded memory growth
93
+ private val topicCreationErrorCache = Collections .synchronizedMap(
94
+ new java.util.LinkedHashMap [String , CachedTopicCreationError ](16 , 0.75f , false ) {
95
+ override def removeEldestEntry (eldest : java.util.Map .Entry [String , CachedTopicCreationError ]): Boolean = {
96
+ size() > maxCacheSize
97
+ }
98
+ }
99
+ )
85
100
86
101
/**
87
102
* Initiate auto topic creation for the given topics.
@@ -126,38 +141,59 @@ class DefaultAutoTopicCreationManager(
126
141
}
127
142
128
143
if (topics.nonEmpty) {
129
- sendCreateTopicRequest (topics, Some (requestContext))
144
+ sendCreateTopicRequestWithErrorCaching (topics, Some (requestContext))
130
145
}
131
146
}
132
147
133
148
override def getTopicCreationErrors (
134
149
topicNames : Set [String ],
135
150
errorCacheTtlMs : Long
136
151
): Map [String , String ] = {
137
- val currentTime = time.milliseconds()
152
+ // Proactively expire old entries using the provided TTL
153
+ expireOldEntries(errorCacheTtlMs)
154
+
138
155
val errors = mutable.Map .empty[String , String ]
139
- val expiredKeys = mutable.Set .empty[String ]
140
156
141
- // Check requested topics and collect expired keys
157
+ // Check requested topics
142
158
topicNames.foreach { topicName =>
143
159
Option (topicCreationErrorCache.get(topicName)) match {
144
- case Some (cachedError) if (currentTime - cachedError.timestamp) <= errorCacheTtlMs =>
145
- errors.put(topicName, cachedError.errorMessage)
146
- case Some (_) =>
147
- expiredKeys += topicName
160
+ case Some (error) =>
161
+ errors.put(topicName, error.errorMessage)
148
162
case None =>
149
163
}
150
164
}
151
165
152
- // Remove expired entries
153
- expiredKeys.foreach { key =>
154
- topicCreationErrorCache.remove(key)
155
- debug(s " Removed expired topic creation error cache entry for $key" )
156
- }
157
-
158
166
errors.toMap
159
167
}
160
168
169
+ /**
170
+ * Remove expired entries from the cache using the provided TTL.
171
+ * Since we use LinkedHashMap with insertion order, we only need to check
172
+ * entries from the beginning until we find a non-expired entry.
173
+ */
174
+ private def expireOldEntries (ttlMs : Long ): Unit = {
175
+ val currentTime = time.milliseconds()
176
+
177
+ // Iterate and remove expired entries
178
+ val iterator = topicCreationErrorCache.entrySet().iterator()
179
+
180
+ breakable {
181
+ while (iterator.hasNext) {
182
+ val entry = iterator.next()
183
+ val cachedError = entry.getValue
184
+
185
+ if (currentTime - cachedError.timestamp > ttlMs) {
186
+ iterator.remove()
187
+ debug(s " Removed expired topic creation error cache entry for ${entry.getKey}" )
188
+ } else {
189
+ // Since entries are in insertion order, if this entry is not expired,
190
+ // all following entries are also not expired
191
+ break()
192
+ }
193
+ }
194
+ }
195
+ }
196
+
161
197
private def sendCreateTopicRequest (
162
198
creatableTopics : Map [String , CreatableTopic ],
163
199
requestContext : Option [RequestContext ]
@@ -182,18 +218,11 @@ class DefaultAutoTopicCreationManager(
182
218
if (response.authenticationException() != null ) {
183
219
val authException = response.authenticationException()
184
220
warn(s " Auto topic creation failed for ${creatableTopics.keys} with authentication exception: ${authException.getMessage}" )
185
- cacheTopicCreationErrors(creatableTopics.keys.toSet, authException.getMessage)
186
221
} else if (response.versionMismatch() != null ) {
187
222
val versionException = response.versionMismatch()
188
223
warn(s " Auto topic creation failed for ${creatableTopics.keys} with version mismatch exception: ${versionException.getMessage}" )
189
- cacheTopicCreationErrors(creatableTopics.keys.toSet, versionException.getMessage)
190
224
} else {
191
- response.responseBody() match {
192
- case createTopicsResponse : CreateTopicsResponse =>
193
- cacheTopicCreationErrorsFromResponse(createTopicsResponse)
194
- case _ =>
195
- debug(s " Auto topic creation completed for ${creatableTopics.keys} with response ${response.responseBody}. " )
196
- }
225
+ debug(s " Auto topic creation completed for ${creatableTopics.keys} with response ${response.responseBody}. " )
197
226
}
198
227
}
199
228
}
@@ -319,6 +348,80 @@ class DefaultAutoTopicCreationManager(
319
348
(creatableTopics, uncreatableTopics)
320
349
}
321
350
351
+ private def sendCreateTopicRequestWithErrorCaching (
352
+ creatableTopics : Map [String , CreatableTopic ],
353
+ requestContext : Option [RequestContext ]
354
+ ): Seq [MetadataResponseTopic ] = {
355
+ val topicsToCreate = new CreateTopicsRequestData .CreatableTopicCollection (creatableTopics.size)
356
+ topicsToCreate.addAll(creatableTopics.values.asJavaCollection)
357
+
358
+ val createTopicsRequest = new CreateTopicsRequest .Builder (
359
+ new CreateTopicsRequestData ()
360
+ .setTimeoutMs(config.requestTimeoutMs)
361
+ .setTopics(topicsToCreate)
362
+ )
363
+
364
+ val requestCompletionHandler = new ControllerRequestCompletionHandler {
365
+ override def onTimeout (): Unit = {
366
+ clearInflightRequests(creatableTopics)
367
+ debug(s " Auto topic creation timed out for ${creatableTopics.keys}. " )
368
+ }
369
+
370
+ override def onComplete (response : ClientResponse ): Unit = {
371
+ clearInflightRequests(creatableTopics)
372
+ if (response.authenticationException() != null ) {
373
+ val authException = response.authenticationException()
374
+ warn(s " Auto topic creation failed for ${creatableTopics.keys} with authentication exception: ${authException.getMessage}" )
375
+ cacheTopicCreationErrors(creatableTopics.keys.toSet, authException.getMessage)
376
+ } else if (response.versionMismatch() != null ) {
377
+ val versionException = response.versionMismatch()
378
+ warn(s " Auto topic creation failed for ${creatableTopics.keys} with version mismatch exception: ${versionException.getMessage}" )
379
+ cacheTopicCreationErrors(creatableTopics.keys.toSet, versionException.getMessage)
380
+ } else {
381
+ response.responseBody() match {
382
+ case createTopicsResponse : CreateTopicsResponse =>
383
+ cacheTopicCreationErrorsFromResponse(createTopicsResponse)
384
+ case _ =>
385
+ debug(s " Auto topic creation completed for ${creatableTopics.keys} with response ${response.responseBody}. " )
386
+ }
387
+ }
388
+ }
389
+ }
390
+
391
+ val request = requestContext.map { context =>
392
+ val requestVersion =
393
+ channelManager.controllerApiVersions.toScala match {
394
+ case None =>
395
+ // We will rely on the Metadata request to be retried in the case
396
+ // that the latest version is not usable by the controller.
397
+ ApiKeys .CREATE_TOPICS .latestVersion()
398
+ case Some (nodeApiVersions) =>
399
+ nodeApiVersions.latestUsableVersion(ApiKeys .CREATE_TOPICS )
400
+ }
401
+
402
+ // Borrow client information such as client id and correlation id from the original request,
403
+ // in order to correlate the create request with the original metadata request.
404
+ val requestHeader = new RequestHeader (ApiKeys .CREATE_TOPICS ,
405
+ requestVersion,
406
+ context.clientId,
407
+ context.correlationId)
408
+ ForwardingManager .buildEnvelopeRequest(context,
409
+ createTopicsRequest.build(requestVersion).serializeWithHeader(requestHeader))
410
+ }.getOrElse(createTopicsRequest)
411
+
412
+ channelManager.sendRequest(request, requestCompletionHandler)
413
+
414
+ val creatableTopicResponses = creatableTopics.keySet.toSeq.map { topic =>
415
+ new MetadataResponseTopic ()
416
+ .setErrorCode(Errors .UNKNOWN_TOPIC_OR_PARTITION .code)
417
+ .setName(topic)
418
+ .setIsInternal(Topic .isInternal(topic))
419
+ }
420
+
421
+ info(s " Sent auto-creation request for ${creatableTopics.keys} to the active controller. " )
422
+ creatableTopicResponses
423
+ }
424
+
322
425
private def cacheTopicCreationErrors (topicNames : Set [String ], errorMessage : String ): Unit = {
323
426
topicNames.foreach { topicName =>
324
427
topicCreationErrorCache.put(topicName, CachedTopicCreationError (errorMessage, time))
@@ -337,7 +440,6 @@ class DefaultAutoTopicCreationManager(
337
440
)
338
441
debug(s " Cached topic creation error for ${topicResult.name()}: $errorMessage" )
339
442
}
340
-
341
443
}
342
444
}
343
445
0 commit comments