|
18 | 18 | */ |
19 | 19 | package org.apache.pulsar.io.jdbc; |
20 | 20 |
|
| 21 | +import static org.mockito.ArgumentMatchers.any; |
21 | 22 | import static org.mockito.Mockito.doAnswer; |
| 23 | +import static org.mockito.Mockito.doThrow; |
22 | 24 | import static org.mockito.Mockito.mock; |
| 25 | +import static org.mockito.Mockito.verify; |
23 | 26 | import static org.mockito.Mockito.when; |
24 | 27 | import com.google.common.collect.ImmutableMap; |
25 | 28 | import com.google.common.collect.Maps; |
| 29 | +import java.sql.PreparedStatement; |
| 30 | +import java.sql.SQLException; |
26 | 31 | import java.util.Arrays; |
27 | 32 | import java.util.HashMap; |
28 | 33 | import java.util.List; |
|
56 | 61 | import org.apache.pulsar.common.schema.SchemaType; |
57 | 62 | import org.apache.pulsar.functions.api.Record; |
58 | 63 | import org.apache.pulsar.functions.source.PulsarRecord; |
| 64 | +import org.apache.pulsar.io.core.SinkContext; |
59 | 65 | import org.awaitility.Awaitility; |
60 | 66 | import org.testng.Assert; |
61 | 67 | import org.testng.annotations.AfterMethod; |
@@ -133,7 +139,9 @@ protected void configure(Map<String, Object> configuration) { |
133 | 139 |
|
134 | 140 | @AfterMethod(alwaysRun = true) |
135 | 141 | public void tearDown() throws Exception { |
136 | | - jdbcSink.close(); |
| 142 | + if (jdbcSink != null) { |
| 143 | + jdbcSink.close(); |
| 144 | + } |
137 | 145 | sqliteUtils.tearDown(); |
138 | 146 | } |
139 | 147 |
|
@@ -860,6 +868,70 @@ public void testNullValueAction(NullValueActionTestConfig config) throws Excepti |
860 | 868 | } |
861 | 869 | } |
862 | 870 |
|
| 871 | + /** |
| 872 | + * Test that fatal() is called when an unrecoverable exception occurs during flush. |
| 873 | + * This verifies the PIP-297 implementation for proper termination of the sink. |
| 874 | + * |
| 875 | + * The test works by: |
| 876 | + * 1. Opening the sink with a valid table (so open() succeeds) |
| 877 | + * 2. Using reflection to replace the insertStatement with a mock that throws SQLException |
| 878 | + * 3. Writing a record to trigger flush |
| 879 | + * 4. Verifying that fatal() was called with the exception |
| 880 | + */ |
| 881 | + @Test |
| 882 | + public void testFatalCalledOnFlushException() throws Exception { |
| 883 | + jdbcSink.close(); |
| 884 | + jdbcSink = null; |
| 885 | + |
| 886 | + String jdbcUrl = sqliteUtils.sqliteUri(); |
| 887 | + Map<String, Object> conf = Maps.newHashMap(); |
| 888 | + conf.put("jdbcUrl", jdbcUrl); |
| 889 | + conf.put("tableName", tableName); // Use valid table so open() succeeds |
| 890 | + conf.put("key", "field3"); |
| 891 | + conf.put("nonKey", "field1,field2"); |
| 892 | + conf.put("batchSize", 1); |
| 893 | + |
| 894 | + SinkContext mockSinkContext = mock(SinkContext.class); |
| 895 | + AtomicReference<Throwable> fatalException = new AtomicReference<>(); |
| 896 | + doAnswer(invocation -> { |
| 897 | + fatalException.set(invocation.getArgument(0)); |
| 898 | + return null; |
| 899 | + }).when(mockSinkContext).fatal(any(Throwable.class)); |
| 900 | + |
| 901 | + SqliteJdbcAutoSchemaSink sinkWithContext = new SqliteJdbcAutoSchemaSink(); |
| 902 | + try { |
| 903 | + sinkWithContext.open(conf, mockSinkContext); |
| 904 | + |
| 905 | + // Create a mock PreparedStatement that throws SQLException on execute() |
| 906 | + PreparedStatement mockStatement = mock(PreparedStatement.class); |
| 907 | + SQLException simulatedException = new SQLException("Simulated database connection failure"); |
| 908 | + doThrow(simulatedException).when(mockStatement).execute(); |
| 909 | + doThrow(simulatedException).when(mockStatement).executeBatch(); |
| 910 | + |
| 911 | + // Use reflection to replace the insertStatement with our mock |
| 912 | + FieldUtils.writeField(sinkWithContext, "insertStatement", mockStatement, true); |
| 913 | + |
| 914 | + Foo insertObj = new Foo("f1", "f2", 1); |
| 915 | + Map<String, String> props = Maps.newHashMap(); |
| 916 | + props.put("ACTION", "INSERT"); |
| 917 | + CompletableFuture<Boolean> future = new CompletableFuture<>(); |
| 918 | + sinkWithContext.write(createMockFooRecord(insertObj, props, future)); |
| 919 | + |
| 920 | + // Wait for the flush to complete and fail |
| 921 | + Awaitility.await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { |
| 922 | + verify(mockSinkContext).fatal(any(Throwable.class)); |
| 923 | + Assert.assertNotNull(fatalException.get()); |
| 924 | + Assert.assertTrue(fatalException.get() instanceof SQLException); |
| 925 | + Assert.assertEquals(fatalException.get().getMessage(), "Simulated database connection failure"); |
| 926 | + }); |
| 927 | + |
| 928 | + // Verify the record was failed (not acked) |
| 929 | + Assert.assertFalse(future.get(1, TimeUnit.SECONDS)); |
| 930 | + } finally { |
| 931 | + sinkWithContext.close(); |
| 932 | + } |
| 933 | + } |
| 934 | + |
863 | 935 | @SuppressWarnings("unchecked") |
864 | 936 | private Record<GenericObject> createMockFooRecord(Foo record, Map<String, String> actionProperties, |
865 | 937 | CompletableFuture<Boolean> future) { |
|
0 commit comments