|
|
@@ -1,13 +1,15 @@
|
|
|
package com.adx.tencent.retention;
|
|
|
|
|
|
+import ch.qos.logback.classic.Logger;
|
|
|
+import ch.qos.logback.classic.spi.ILoggingEvent;
|
|
|
+import ch.qos.logback.core.read.ListAppender;
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
import org.junit.jupiter.api.Test;
|
|
|
import org.junit.jupiter.params.ParameterizedTest;
|
|
|
import org.junit.jupiter.params.provider.EnumSource;
|
|
|
import javax.sql.DataSource;
|
|
|
import java.sql.*;
|
|
|
-import java.math.BigDecimal;
|
|
|
import java.util.concurrent.atomic.AtomicInteger;
|
|
|
-import java.util.concurrent.atomic.AtomicBoolean;
|
|
|
import static org.junit.jupiter.api.Assertions.*;
|
|
|
import static org.mockito.ArgumentMatchers.*;
|
|
|
import static org.mockito.Mockito.*;
|
|
|
@@ -15,120 +17,78 @@ import static org.mockito.Mockito.*;
|
|
|
class LegacyRetentionStoreTest {
|
|
|
final DataSource ds = mock(DataSource.class);
|
|
|
final Connection connection = mock(Connection.class);
|
|
|
- final PreparedStatement metadata = mock(PreparedStatement.class), primary = mock(PreparedStatement.class),
|
|
|
- clock = mock(PreparedStatement.class), select = mock(PreparedStatement.class), delete = mock(PreparedStatement.class);
|
|
|
- final ResultSet indexResult = mock(ResultSet.class), primaryResult = mock(ResultSet.class),
|
|
|
- clockResult = mock(ResultSet.class), ids = mock(ResultSet.class);
|
|
|
- final Timestamp cutoff = Timestamp.valueOf("2026-09-15 12:00:00");
|
|
|
+ final PreparedStatement metadata = mock(PreparedStatement.class), delete = mock(PreparedStatement.class);
|
|
|
+ final ResultSet result = mock(ResultSet.class);
|
|
|
|
|
|
- LegacyRetentionStore store(int count) throws Exception {
|
|
|
+ LegacyRetentionStore store() throws Exception {
|
|
|
when(ds.getConnection()).thenReturn(connection);
|
|
|
when(connection.prepareStatement(startsWith("SELECT COUNT(*)"))).thenReturn(metadata);
|
|
|
- when(connection.prepareStatement(startsWith("SELECT s.COLUMN_NAME"))).thenReturn(primary);
|
|
|
- when(connection.prepareStatement(startsWith("SELECT DATE_SUB"))).thenReturn(clock);
|
|
|
- when(connection.prepareStatement(startsWith("SELECT `id`"))).thenReturn(select);
|
|
|
when(connection.prepareStatement(startsWith("DELETE FROM"))).thenReturn(delete);
|
|
|
- when(metadata.executeQuery()).thenReturn(indexResult);
|
|
|
- when(indexResult.next()).thenReturn(true);
|
|
|
- when(indexResult.getLong(1)).thenReturn(1L);
|
|
|
- when(primary.executeQuery()).thenReturn(primaryResult);
|
|
|
- when(primaryResult.next()).thenReturn(true, false);
|
|
|
- when(primaryResult.getString(1)).thenReturn("id");
|
|
|
- when(primaryResult.getString(2)).thenReturn("bigint");
|
|
|
- when(clock.executeQuery()).thenReturn(clockResult);
|
|
|
- when(clockResult.next()).thenReturn(true);
|
|
|
- when(clockResult.getTimestamp(1)).thenReturn(cutoff);
|
|
|
- when(select.executeQuery()).thenAnswer(i -> {
|
|
|
- AtomicInteger row = new AtomicInteger();
|
|
|
- when(ids.next()).thenAnswer(x -> row.incrementAndGet() <= count);
|
|
|
- // Noncontiguous unsigned IDs: preserve exact IDs without ranges or signed-long truncation.
|
|
|
- when(ids.getBigDecimal(1)).thenAnswer(x -> new BigDecimal("9223372036854775808").add(BigDecimal.valueOf(row.get() * 2L)));
|
|
|
- return ids;
|
|
|
- });
|
|
|
- when(delete.executeUpdate()).thenReturn(Math.min(count, 5000));
|
|
|
+ when(metadata.executeQuery()).thenReturn(result);
|
|
|
+ when(result.next()).thenReturn(true);
|
|
|
+ when(result.getLong(1)).thenReturn(1L);
|
|
|
+ when(delete.executeUpdate()).thenReturn(50000);
|
|
|
return new LegacyRetentionStore(ds);
|
|
|
}
|
|
|
|
|
|
@ParameterizedTest @EnumSource(LegacyRetentionStore.Table.class)
|
|
|
- void selectsBoundedIdsAndDeletesOnlyThoseIdsWithSameDateGuard(LegacyRetentionStore.Table table) throws Exception {
|
|
|
- assertEquals(2, store(2).deleteBatch(table));
|
|
|
- verify(connection).prepareStatement("SELECT `id` FROM " + table.qualifiedName()
|
|
|
- + " WHERE `created_at` < ? ORDER BY `created_at`, `id` LIMIT ?");
|
|
|
- verify(connection).prepareStatement("SELECT DATE_SUB(CURRENT_TIMESTAMP(3), INTERVAL 30 DAY)");
|
|
|
- verify(clock).executeQuery();
|
|
|
- verify(select).setTimestamp(1, cutoff); verify(select).setInt(2, 50000);
|
|
|
- verify(connection).prepareStatement("DELETE FROM " + table.qualifiedName() + " WHERE `id` IN (?,?) AND `created_at` < ?");
|
|
|
- verify(delete).setBigDecimal(1, new BigDecimal("9223372036854775810"));
|
|
|
- verify(delete).setBigDecimal(2, new BigDecimal("9223372036854775812"));
|
|
|
- verify(delete).setTimestamp(3, cutoff); verify(delete).setQueryTimeout(30);
|
|
|
+ void oneDeletePerBatchPreservesThirtyDayGuardAndFiftyThousandLimit(LegacyRetentionStore.Table table) throws Exception {
|
|
|
+ assertEquals(50000, store().deleteBatch(table));
|
|
|
+ verify(connection).prepareStatement("DELETE FROM " + table.qualifiedName()
|
|
|
+ + " WHERE `created_at` < DATE_SUB(CURRENT_TIMESTAMP(3), INTERVAL 30 DAY) ORDER BY `created_at` LIMIT ?");
|
|
|
+ verify(delete).setInt(1, 50000); verify(delete).setQueryTimeout(30);
|
|
|
+ verify(delete, times(1)).executeUpdate();
|
|
|
verify(connection).setAutoCommit(true);
|
|
|
- verify(ids).close(); verify(delete).close(); verify(connection).close();
|
|
|
- }
|
|
|
-
|
|
|
- @Test void largeRoundUsesSmallAutoCommittedDeletes() throws Exception {
|
|
|
- assertEquals(50000, store(50000).deleteBatch(LegacyRetentionStore.Table.BID));
|
|
|
- verify(delete, times(10)).executeUpdate();
|
|
|
- verify(delete, times(10)).setTimestamp(5001, cutoff);
|
|
|
- verify(select, times(1)).executeQuery();
|
|
|
- }
|
|
|
-
|
|
|
- @Test void partialLastChunkUsesOnlyRemainingIds() throws Exception {
|
|
|
- LegacyRetentionStore store = store(5001);
|
|
|
- when(delete.executeUpdate()).thenReturn(5000, 1);
|
|
|
- assertEquals(5001, store.deleteBatch(LegacyRetentionStore.Table.BID));
|
|
|
- verify(delete).setTimestamp(5001, cutoff); verify(delete).setTimestamp(2, cutoff);
|
|
|
- verify(delete, times(2)).executeUpdate();
|
|
|
- }
|
|
|
-
|
|
|
- @Test void emptyCandidatesNeverIssueDelete() throws Exception {
|
|
|
- assertEquals(0, store(0).deleteBatch(LegacyRetentionStore.Table.TRACKING));
|
|
|
- verifyNoInteractions(delete);
|
|
|
+ verify(connection, times(2)).prepareStatement(anyString()); // Metadata + DELETE, no ID lookup.
|
|
|
+ verify(result).close(); verify(delete).close(); verify(connection).close();
|
|
|
}
|
|
|
|
|
|
- @Test void missingIndexBlocksDeletionAndIsRechecked() throws Exception {
|
|
|
- LegacyRetentionStore store = store(1);
|
|
|
- when(indexResult.getLong(1)).thenReturn(0L, 1L);
|
|
|
- assertThrows(SQLException.class, () -> store.deleteBatch(LegacyRetentionStore.Table.BID));
|
|
|
- verifyNoInteractions(select, delete);
|
|
|
- assertEquals(1, store.deleteBatch(LegacyRetentionStore.Table.BID));
|
|
|
- verify(metadata, times(2)).executeQuery();
|
|
|
+ @Test void emptyTableReturnsZeroWithoutExtraQueryOrPrimaryKeyRequirement() throws Exception {
|
|
|
+ LegacyRetentionStore store = store();
|
|
|
+ when(delete.executeUpdate()).thenReturn(0);
|
|
|
+ assertEquals(0, store.deleteBatch(LegacyRetentionStore.Table.TRACKING));
|
|
|
+ verify(connection, times(2)).prepareStatement(anyString());
|
|
|
}
|
|
|
|
|
|
- @Test void compositePrimaryKeyBlocksDeletion() throws Exception {
|
|
|
- LegacyRetentionStore store = store(1);
|
|
|
- when(primaryResult.next()).thenReturn(true, true);
|
|
|
- assertThrows(SQLException.class, () -> store.deleteBatch(LegacyRetentionStore.Table.BID));
|
|
|
- verifyNoInteractions(select, delete);
|
|
|
+ @Test void missingIndexIsLoggedWithExplicitValidationFailureAndRetriedLater() throws Exception {
|
|
|
+ LegacyRetentionStore store = store();
|
|
|
+ when(result.getLong(1)).thenReturn(0L, 1L);
|
|
|
+ Logger logger = (Logger) LoggerFactory.getLogger(LegacyRetentionStore.class);
|
|
|
+ ListAppender<ILoggingEvent> appender = new ListAppender<>(); appender.start(); logger.addAppender(appender);
|
|
|
+ try {
|
|
|
+ assertThrows(SQLException.class, () -> store.deleteBatch(LegacyRetentionStore.Table.TRACKING));
|
|
|
+ verifyNoInteractions(delete);
|
|
|
+ String message = appender.list.get(0).getFormattedMessage();
|
|
|
+ assertTrue(message.contains("mode=direct"));
|
|
|
+ assertTrue(message.contains("complete=false reason=sql_error phase=validation"));
|
|
|
+ assertTrue(message.contains("requires a date/time created_at column"));
|
|
|
+ assertEquals(50000, store.deleteBatch(LegacyRetentionStore.Table.TRACKING));
|
|
|
+ } finally { logger.detachAppender(appender); appender.stop(); }
|
|
|
}
|
|
|
|
|
|
- @Test void failedChunkClosesResourcesAndDoesNotRunRemainingChunks() throws Exception {
|
|
|
- LegacyRetentionStore store = store(10001);
|
|
|
- when(delete.executeUpdate()).thenReturn(5000).thenThrow(new SQLException("timeout"));
|
|
|
- assertThrows(SQLException.class, () -> store.deleteBatch(LegacyRetentionStore.Table.BID));
|
|
|
- verify(delete, times(2)).executeUpdate(); verify(delete, times(2)).close(); verify(connection).close();
|
|
|
+ @Test void deleteFailureReportsSqlStateAndVendorCodeAndCanRetry() throws Exception {
|
|
|
+ LegacyRetentionStore store = store();
|
|
|
+ when(delete.executeUpdate()).thenThrow(new SQLException("lock wait timeout", "HY000", 1205)).thenReturn(50000);
|
|
|
+ Logger logger = (Logger) LoggerFactory.getLogger(LegacyRetentionStore.class);
|
|
|
+ ListAppender<ILoggingEvent> appender = new ListAppender<>(); appender.start(); logger.addAppender(appender);
|
|
|
+ try {
|
|
|
+ assertThrows(SQLException.class, () -> store.deleteBatch(LegacyRetentionStore.Table.BID));
|
|
|
+ String message = appender.list.get(0).getFormattedMessage();
|
|
|
+ assertTrue(message.contains("reason=sql_error phase=delete"));
|
|
|
+ assertTrue(message.contains("sqlState=HY000 errorCode=1205 error=lock wait timeout"));
|
|
|
+ assertEquals(50000, store.deleteBatch(LegacyRetentionStore.Table.BID));
|
|
|
+ verify(connection, times(2)).close(); verify(delete, times(2)).close();
|
|
|
+ } finally { logger.detachAppender(appender); appender.stop(); }
|
|
|
}
|
|
|
|
|
|
- @Test void nextRoundReselectsRemainingIdsAfterPartialFailure() throws Exception {
|
|
|
- LegacyRetentionStore store = store(5001);
|
|
|
- when(delete.executeUpdate()).thenReturn(5000).thenThrow(new SQLException("timeout"));
|
|
|
- assertThrows(SQLException.class, () -> store.deleteBatch(LegacyRetentionStore.Table.BID));
|
|
|
- ResultSet remaining = mock(ResultSet.class);
|
|
|
- when(remaining.next()).thenReturn(true, false);
|
|
|
- when(remaining.getBigDecimal(1)).thenReturn(new BigDecimal("9223372036854785810"));
|
|
|
- when(select.executeQuery()).thenReturn(remaining);
|
|
|
- doReturn(1).when(delete).executeUpdate();
|
|
|
- assertEquals(1, store.deleteBatch(LegacyRetentionStore.Table.BID));
|
|
|
- verify(select, times(2)).executeQuery();
|
|
|
- verify(delete, times(3)).executeUpdate();
|
|
|
- verify(connection, times(2)).close();
|
|
|
- verify(connection, never()).rollback(); // Successful chunks were already committed.
|
|
|
+ @Test void lossOfLeadershipBeforeDeleteDoesNotIssueDelete() throws Exception {
|
|
|
+ LegacyRetentionStore store = store(); AtomicInteger checks = new AtomicInteger();
|
|
|
+ assertEquals(0, store.deleteBatch(LegacyRetentionStore.Table.BID, () -> checks.incrementAndGet() > 1));
|
|
|
+ verifyNoInteractions(delete); verify(connection).close();
|
|
|
}
|
|
|
|
|
|
- @Test void lostLeadershipStopsBetweenChunks() throws Exception {
|
|
|
- LegacyRetentionStore store = store(10000);
|
|
|
- AtomicBoolean stop = new AtomicBoolean();
|
|
|
- when(delete.executeUpdate()).thenAnswer(i -> { stop.set(true); return 5000; });
|
|
|
- assertEquals(5000, store.deleteBatch(LegacyRetentionStore.Table.BID, stop::get));
|
|
|
- verify(delete, times(1)).executeUpdate();
|
|
|
+ @Test void alreadyStoppedDoesNotBorrowConnection() throws Exception {
|
|
|
+ assertEquals(0, new LegacyRetentionStore(ds).deleteBatch(LegacyRetentionStore.Table.BID, () -> true));
|
|
|
+ verifyNoInteractions(ds);
|
|
|
}
|
|
|
}
|