Repository navigation
fix(inkless:delete): do not sleep inside the topic purger tick [KC-349] #792
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,6 +32,7 @@ | |
| import org.apache.kafka.common.serialization.StringSerializer; | ||
| import org.apache.kafka.common.test.KafkaClusterTestKit; | ||
| import org.apache.kafka.common.test.TestKitNodes; | ||
| import org.apache.kafka.common.utils.LogCaptureAppender; | ||
| import org.apache.kafka.coordinator.group.GroupCoordinatorConfig; | ||
| import org.apache.kafka.server.config.ServerConfigs; | ||
| import org.apache.kafka.test.TestUtils; | ||
|
|
@@ -50,10 +51,8 @@ | |
| import java.sql.PreparedStatement; | ||
| import java.sql.ResultSet; | ||
| import java.sql.SQLException; | ||
| import java.util.ArrayList; | ||
| import java.util.Collections; | ||
| import java.util.HashMap; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
| import java.util.UUID; | ||
| import java.util.concurrent.ExecutionException; | ||
|
|
@@ -64,6 +63,7 @@ | |
| import io.aiven.inkless.config.InklessConfig; | ||
| import io.aiven.inkless.control_plane.postgres.PostgresControlPlane; | ||
| import io.aiven.inkless.control_plane.postgres.PostgresControlPlaneConfig; | ||
| import io.aiven.inkless.delete.TopicPurger; | ||
| import io.aiven.inkless.storage_backend.s3.S3Storage; | ||
| import io.aiven.inkless.storage_backend.s3.S3StorageConfig; | ||
| import io.aiven.inkless.test_utils.InklessPostgreSQLContainer; | ||
|
|
@@ -137,7 +137,7 @@ public void setup(final TestInfo testInfo) throws Exception { | |
| .setConfigProp(InklessConfig.PREFIX + InklessConfig.PRODUCE_BUFFER_MAX_BYTES_CONFIG, Integer.toString(PRODUCE_BUFFER_MAX_BYTES)) | ||
| // Drain and object delete must finish inside the wait windows below. The batch-coordinate | ||
| // cache TTL must stay at most half of file.cleaner.retention.period.ms. Cap the purger so | ||
| // the test can observe leftover batches between cycles. | ||
| // a single cycle cannot finish the drain. | ||
| .setConfigProp(InklessConfig.PREFIX + InklessConfig.TOPIC_PURGER_INTERVAL_MS_CONFIG, "1000") | ||
| .setConfigProp(InklessConfig.PREFIX + InklessConfig.TOPIC_PURGER_MAX_BATCHES_PER_CYCLE_CONFIG, Integer.toString(MAX_BATCHES_PER_CYCLE)) | ||
| .setConfigProp(InklessConfig.PREFIX + InklessConfig.FILE_CLEANER_INTERVAL_MS_CONFIG, "1000") | ||
|
|
@@ -185,20 +185,23 @@ public void testDeleteTopicsReturnsBeforeBackgroundPurge() throws Exception { | |
| assertEquals(NUM_PARTITIONS, beforeDelete.logCount(), "Every partition must have a log row"); | ||
| assertEquals(0, beforeDelete.deletedLogCount(), "Live logs must not be stamped deleted"); | ||
|
|
||
| admin.deleteTopics(Collections.singletonList(TOPIC_NAME)).all().get(30, TimeUnit.SECONDS); | ||
| try (LogCaptureAppender appender = LogCaptureAppender.createAndRegister(TopicPurger.class)) { | ||
| admin.deleteTopics(Collections.singletonList(TOPIC_NAME)).all().get(30, TimeUnit.SECONDS); | ||
|
|
||
| final ControlPlaneSnapshot afterDelete = readControlPlaneSnapshot(topicId); | ||
| log.info("Control plane snapshot immediately after delete: {}", afterDelete); | ||
| if (afterDelete.logCount() > 0) { | ||
| assertEquals(afterDelete.logCount(), afterDelete.deletedLogCount(), | ||
| "DELETE_TOPICS must stamp logs.deleted_at rather than drop the rows"); | ||
| } | ||
| assertTrue(afterDelete.batchCount() >= MIN_BATCHES_FOR_MULTI_CYCLE, | ||
| "DELETE_TOPICS must return before TopicPurger drains the batches, got " | ||
| + afterDelete.batchCount()); | ||
| final ControlPlaneSnapshot afterDelete = readControlPlaneSnapshot(topicId); | ||
| log.info("Control plane snapshot immediately after delete: {}", afterDelete); | ||
| if (afterDelete.logCount() > 0) { | ||
| assertEquals(afterDelete.logCount(), afterDelete.deletedLogCount(), | ||
| "DELETE_TOPICS must stamp logs.deleted_at rather than drop the rows"); | ||
| } | ||
| assertTrue(afterDelete.batchCount() >= MIN_BATCHES_FOR_MULTI_CYCLE, | ||
| "DELETE_TOPICS must return before TopicPurger drains the batches, got " | ||
| + afterDelete.batchCount()); | ||
|
|
||
| waitUntilPurged(topicId); | ||
| assertSaturatedAcrossMultipleCycles(appender, afterDelete.batchCount()); | ||
| } | ||
| waitUntilTopicGone(admin); | ||
| waitUntilPurgedAcrossMultipleCycles(topicId, afterDelete.batchCount()); | ||
| } | ||
|
|
||
| try (S3Client s3 = s3Container.getS3Client()) { | ||
|
|
@@ -275,40 +278,32 @@ private void waitUntilTopicGone(Admin admin) throws InterruptedException { | |
| }, 60_000, () -> "Topic must disappear from Kafka metadata after DELETE_TOPICS"); | ||
| } | ||
|
|
||
| /** | ||
| * Polls until the control-plane rows are gone and requires a leftover batch count in between. | ||
| * With {@code topic.purger.max.batches.per.cycle} capped, one cycle cannot finish the drain. | ||
| */ | ||
| private void waitUntilPurgedAcrossMultipleCycles(Uuid topicId, long batchesAtDelete) throws InterruptedException { | ||
| final List<Long> observedBatchCounts = new ArrayList<>(); | ||
| observedBatchCounts.add(batchesAtDelete); | ||
| private void waitUntilPurged(Uuid topicId) throws InterruptedException { | ||
| final AtomicReference<ControlPlaneSnapshot> last = new AtomicReference<>(); | ||
| boolean sawPartialDrain = false; | ||
| final long deadline = System.currentTimeMillis() + 90_000; | ||
|
|
||
| while (System.currentTimeMillis() < deadline) { | ||
| TestUtils.waitForCondition(() -> { | ||
| ControlPlaneSnapshot snapshot = readControlPlaneSnapshot(topicId); | ||
| last.set(snapshot); | ||
| long batches = snapshot.batchCount(); | ||
| if (observedBatchCounts.get(observedBatchCounts.size() - 1) != batches) { | ||
| observedBatchCounts.add(batches); | ||
| ControlPlaneSnapshot previous = last.getAndSet(snapshot); | ||
| if (previous == null || !previous.equals(snapshot)) { | ||
| log.info("TopicPurger progress: {}", snapshot); | ||
| } | ||
| if (batches > 0 && batches < batchesAtDelete) { | ||
| sawPartialDrain = true; | ||
| } | ||
| if (snapshot.isFullyPurged()) { | ||
| break; | ||
| } | ||
| Thread.sleep(100); | ||
| } | ||
| return snapshot.isFullyPurged(); | ||
| }, 90_000, () -> "TopicPurger must drop logs, batches, and producer_state; last seen: " + last.get()); | ||
| } | ||
|
|
||
| assertTrue(last.get() != null && last.get().isFullyPurged(), | ||
| "TopicPurger must drop logs, batches, and producer_state; last seen: " + last.get() | ||
| + "; batch counts: " + observedBatchCounts); | ||
| assertTrue(sawPartialDrain, | ||
| "TopicPurger must drain batches across multiple cycles (max " | ||
| + MAX_BATCHES_PER_CYCLE + " per cycle); observed batch counts: " + observedBatchCounts); | ||
| /** | ||
| * One cycle cannot finish the drain: each broker deletes at most | ||
| * {@code topic.purger.max.batches.per.cycle} batches. Count saturated cycles from TopicPurger | ||
| * logs instead of a {@code COUNT(*)} poll, which can miss leftovers between 1s ticks. | ||
| */ | ||
| private static void assertSaturatedAcrossMultipleCycles(LogCaptureAppender appender, long batchesAtDelete) { | ||
| long saturated = appender.getMessages().stream() | ||
| .filter(message -> message.contains("per-cycle cap reached")) | ||
| .count(); | ||
| assertTrue(saturated >= batchesAtDelete - NUM_BROKERS, | ||
| "TopicPurger must hit the per-cycle cap across multiple cycles (max " | ||
| + MAX_BATCHES_PER_CYCLE + " per cycle); saturated=" + saturated | ||
| + " batchesAtDelete=" + batchesAtDelete | ||
| + " messages=" + appender.getMessages()); | ||
|
Comment on lines
+293
to
+306
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe worth adding a doc for We could also consider to move the saturated calculation to a wait if condition, to expect all brokers to do the logging: |
||
| } | ||
|
|
||
| private record ControlPlaneSnapshot( | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -27,7 +27,6 @@ | |||||||||||
| import java.io.IOException; | ||||||||||||
| import java.time.Duration; | ||||||||||||
| import java.util.concurrent.atomic.AtomicInteger; | ||||||||||||
| import java.util.function.Supplier; | ||||||||||||
|
|
||||||||||||
| import io.aiven.inkless.TimeUtils; | ||||||||||||
| import io.aiven.inkless.common.SharedState; | ||||||||||||
|
|
@@ -42,8 +41,11 @@ public class TopicPurger implements Runnable, Closeable { | |||||||||||
| final int maxBatchesPerCycle; | ||||||||||||
| final TopicPurgerMetrics metrics; | ||||||||||||
| private final ExponentialBackoff errorBackoff = new ExponentialBackoff(100, 2, 60 * 1000, 0.2); | ||||||||||||
| private final Supplier<Long> noWorkBackoffSupplier; | ||||||||||||
| private final AtomicInteger attempts = new AtomicInteger(); | ||||||||||||
| // KafkaScheduler uses scheduleAtFixedRate. Sleeping inside run() queues missed ticks, and | ||||||||||||
| // the next work cycle bursts past topic.purger.max.batches.per.cycle / interval. Skip the | ||||||||||||
| // tick until this time instead. | ||||||||||||
|
Comment on lines
+45
to
+47
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: a shorter version
Suggested change
|
||||||||||||
| private volatile long nextEligibleMs; | ||||||||||||
|
|
||||||||||||
| public TopicPurger(SharedState sharedState) { | ||||||||||||
| this( | ||||||||||||
|
|
@@ -61,14 +63,13 @@ public TopicPurger(SharedState sharedState) { | |||||||||||
| this.controlPlane = controlPlane; | ||||||||||||
| this.maxBatchesPerCycle = maxBatchesPerCycle; | ||||||||||||
| this.metrics = new TopicPurgerMetrics(time); | ||||||||||||
|
|
||||||||||||
| final int noWorkBackoffDuration = 10 * 1000; | ||||||||||||
| final var noWorkBackoff = new ExponentialBackoff(noWorkBackoffDuration, 1, noWorkBackoffDuration * 2, 0.2); | ||||||||||||
| noWorkBackoffSupplier = () -> noWorkBackoff.backoff(1); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| @Override | ||||||||||||
| public void run() { | ||||||||||||
| if (time.milliseconds() < nextEligibleMs) { | ||||||||||||
| return; | ||||||||||||
| } | ||||||||||||
| try { | ||||||||||||
| metrics.recordTopicPurgerStart(); | ||||||||||||
| final PurgeDeletedLogsResponse result = TimeUtils.measureDurationMs(time, | ||||||||||||
|
|
@@ -77,9 +78,7 @@ public void run() { | |||||||||||
|
|
||||||||||||
| metrics.recordTopicPurgerWorkRemain(result.moreRemain()); | ||||||||||||
| if (result.isEmpty()) { | ||||||||||||
| final long sleepMillis = noWorkBackoffSupplier.get(); | ||||||||||||
| LOGGER.info("No purge work this cycle, sleeping for {}", Duration.ofMillis(sleepMillis)); | ||||||||||||
| time.sleep(sleepMillis); | ||||||||||||
| LOGGER.debug("No purge work this cycle"); | ||||||||||||
| } else { | ||||||||||||
| final boolean saturated = result.capReached(); | ||||||||||||
| if (saturated) { | ||||||||||||
|
|
@@ -95,12 +94,13 @@ public void run() { | |||||||||||
| } | ||||||||||||
|
|
||||||||||||
| attempts.set(0); | ||||||||||||
| nextEligibleMs = 0L; | ||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. At this point nextEligibleMs is already in the past, so should be fine to delete this one |
||||||||||||
| metrics.recordTopicPurgerCycleSucceeded(); | ||||||||||||
| } catch (final Exception e) { | ||||||||||||
| metrics.recordTopicPurgerError(); | ||||||||||||
| final long backoff = errorBackoff.backoff(attempts.incrementAndGet()); | ||||||||||||
| LOGGER.error("Error while purging deleted logs, waiting for {}", Duration.ofMillis(backoff), e); | ||||||||||||
| time.sleep(backoff); | ||||||||||||
| nextEligibleMs = time.milliseconds() + backoff; | ||||||||||||
| LOGGER.error("Error while purging deleted logs, retrying after {}", Duration.ofMillis(backoff), e); | ||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit:
Suggested change
|
||||||||||||
| } | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
|
|
||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: pin the log level explicitly