-
Notifications
You must be signed in to change notification settings - Fork 15
Return false from bulkUpsert when write result is incomplete #322
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
base: main
Are you sure you want to change the base?
Changes from 2 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 |
|---|---|---|
|
|
@@ -12,6 +12,7 @@ | |
| import static org.hypertrace.core.documentstore.mongo.update.parser.MongoSetOperationParser.SET_CLAUSE; | ||
|
|
||
| import com.fasterxml.jackson.core.JsonProcessingException; | ||
| import com.google.common.annotations.VisibleForTesting; | ||
| import com.mongodb.BasicDBObject; | ||
| import com.mongodb.MongoBulkWriteException; | ||
| import com.mongodb.MongoCommandException; | ||
|
|
@@ -678,6 +679,17 @@ public boolean bulkUpsert(Map<Key, Document> documents) { | |
| try { | ||
| BulkWriteResult result = bulkUpsertImpl(documents); | ||
| LOGGER.debug(result.toString()); | ||
| if (!isBulkUpsertComplete(result, documents.size())) { | ||
| LOGGER.error( | ||
| "Incomplete bulk upsert for documents. requested={}, matched={}, upserted={}," | ||
| + " acknowledged={}, result={}", | ||
| documents.size(), | ||
| result.wasAcknowledged() ? result.getMatchedCount() : -1, | ||
| result.wasAcknowledged() ? result.getUpserts().size() : -1, | ||
| result.wasAcknowledged(), | ||
| result); | ||
| return false; | ||
| } | ||
| return true; | ||
| } catch (IOException | MongoServerException e) { | ||
| LOGGER.error("Error during bulk upsert for documents:{}", documents, e); | ||
|
|
@@ -702,6 +714,21 @@ private BulkWriteResult bulkUpsertImpl(Map<Key, Document> documents) | |
| .get(() -> collection.bulkWrite(bulkCollection, new BulkWriteOptions().ordered(false))); | ||
| } | ||
|
|
||
| /** | ||
| * Each UpdateOne upsert accounts for exactly one matched existing document or one upserted | ||
| * document. Incomplete results (or unacknowledged writes) must not be reported as success. | ||
| */ | ||
| @VisibleForTesting | ||
| static boolean isBulkUpsertComplete(final BulkWriteResult result, final int requestedCount) { | ||
| if (requestedCount == 0) { | ||
| return true; | ||
| } | ||
| if (!result.wasAcknowledged()) { | ||
| return false; | ||
| } | ||
| return result.getMatchedCount() + result.getUpserts().size() == requestedCount; | ||
| } | ||
|
|
||
| @Override | ||
| public CloseableIterator<Document> bulkUpsertAndReturnOlderDocuments(Map<Key, Document> documents) | ||
| throws IOException { | ||
|
|
@@ -714,6 +741,17 @@ public CloseableIterator<Document> bulkUpsertAndReturnOlderDocuments(Map<Key, Do | |
| // Now go ahead and do the bulk upsert. | ||
| BulkWriteResult result = bulkUpsertImpl(documents); | ||
| LOGGER.debug(result.toString()); | ||
| if (!isBulkUpsertComplete(result, documents.size())) { | ||
| LOGGER.error( | ||
| "Incomplete bulk upsert for documents. requested={}, matched={}, upserted={}," | ||
| + " acknowledged={}, result={}", | ||
| documents.size(), | ||
| result.wasAcknowledged() ? result.getMatchedCount() : -1, | ||
| result.wasAcknowledged() ? result.getUpserts().size() : -1, | ||
| result.wasAcknowledged(), | ||
| result); | ||
| throw new IOException("Incomplete bulk upsert."); | ||
|
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. We should close the cursor here or it'll be a resource leak.
Author
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. Fixed — close the Mongo cursor before throwing on incomplete upsert (and on other failure paths before the iterator takes ownership). |
||
| } | ||
|
|
||
| return convertToDocumentIterator(mongoCursor); | ||
| } catch (JsonProcessingException e) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -754,6 +754,14 @@ public boolean bulkUpsert(Map<Key, Document> documents) { | |
| LOGGER.debug("Write result: {}", Arrays.toString(updateCounts)); | ||
| } | ||
|
|
||
| if (!isBatchFullySuccessful(updateCounts, documents.size())) { | ||
| LOGGER.error( | ||
| "Incomplete bulk upsert for documents. requested={}, updateCounts={}", | ||
| documents.size(), | ||
| Arrays.toString(updateCounts)); | ||
| return false; | ||
| } | ||
|
|
||
| return true; | ||
| } catch (BatchUpdateException e) { | ||
| LOGGER.error("BatchUpdateException bulk inserting documents.", e); | ||
|
|
@@ -801,6 +809,13 @@ public CloseableIterator<Document> bulkUpsertAndReturnOlderDocuments(Map<Key, Do | |
| if (LOGGER.isDebugEnabled()) { | ||
| LOGGER.debug("Write result: {}", Arrays.toString(updateCounts)); | ||
| } | ||
| if (!isBatchFullySuccessful(updateCounts, documents.size())) { | ||
| LOGGER.error( | ||
| "Incomplete bulk upsert for documents. requested={}, updateCounts={}", | ||
| documents.size(), | ||
| Arrays.toString(updateCounts)); | ||
| throw new IOException("Incomplete bulk upsert."); | ||
|
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. Close the RS here to prevent leak?
Author
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. Fixed — close the ResultSet in a finally when we do not hand it to |
||
| } | ||
|
|
||
| return new PostgresResultIterator(resultSet); | ||
| } catch (IOException e) { | ||
|
|
@@ -1037,6 +1052,25 @@ private int[] bulkUpsertImpl(Map<Key, Document> documents) throws SQLException, | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * Returns true when every batch entry completed without {@link Statement#EXECUTE_FAILED} and the | ||
| * result length matches the number of operations submitted. | ||
| * | ||
| * <p>{@link Statement#SUCCESS_NO_INFO} (-2) and positive update counts are treated as success. | ||
| */ | ||
| @VisibleForTesting | ||
| static boolean isBatchFullySuccessful(final int[] updateCounts, final int expectedSize) { | ||
| if (updateCounts == null || updateCounts.length != expectedSize) { | ||
|
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. This method will always return Rather, smth like this should help: Can you check this?
Author
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. Checked — agree. Dropped the success-path |
||
| return false; | ||
| } | ||
| for (final int count : updateCounts) { | ||
| if (count == Statement.EXECUTE_FAILED) { | ||
| return false; | ||
| } | ||
| } | ||
| return true; | ||
| } | ||
|
|
||
| @VisibleForTesting | ||
| JsonNode getJsonNodeAtPath(String path, JsonNode rootNode, boolean createPathIfMissing) { | ||
| if (StringUtils.isEmpty(path)) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -34,6 +34,7 @@ | |
| import java.sql.ResultSet; | ||
| import java.sql.ResultSetMetaData; | ||
| import java.sql.SQLException; | ||
| import java.sql.Statement; | ||
| import java.time.Clock; | ||
| import java.util.List; | ||
| import java.util.Optional; | ||
|
|
@@ -54,6 +55,7 @@ | |
| import org.hypertrace.core.documentstore.query.Query; | ||
| import org.hypertrace.core.documentstore.query.SortingSpec; | ||
| import org.junit.jupiter.api.BeforeEach; | ||
| import org.junit.jupiter.api.Nested; | ||
| import org.junit.jupiter.api.Test; | ||
| import org.junit.jupiter.api.extension.ExtendWith; | ||
| import org.mockito.ArgumentCaptor; | ||
|
|
@@ -1237,6 +1239,38 @@ void testUpsertSQLException() throws SQLException, IOException { | |
| verify(mockUpsertPreparedStatement, times(1)).setString(eq(3), any()); | ||
| } | ||
|
|
||
| @Nested | ||
| class BatchFullySuccessful { | ||
|
|
||
| @Test | ||
| void allPositive_returnsTrue() { | ||
|
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. Lets stick to camelCase for consistency.
Author
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. Removed those nested tests (Postgres success-path batch-length check was dropped per your later comment). New IT method names are camelCase. |
||
| assertTrue(PostgresCollection.isBatchFullySuccessful(new int[] {1, 1, 2}, 3)); | ||
| } | ||
|
|
||
| @Test | ||
| void successNoInfo_returnsTrue() { | ||
| assertTrue( | ||
| PostgresCollection.isBatchFullySuccessful( | ||
| new int[] {Statement.SUCCESS_NO_INFO, Statement.SUCCESS_NO_INFO}, 2)); | ||
| } | ||
|
|
||
| @Test | ||
| void executeFailed_returnsFalse() { | ||
| assertFalse( | ||
| PostgresCollection.isBatchFullySuccessful(new int[] {1, Statement.EXECUTE_FAILED}, 2)); | ||
| } | ||
|
|
||
| @Test | ||
| void lengthMismatch_returnsFalse() { | ||
| assertFalse(PostgresCollection.isBatchFullySuccessful(new int[] {1}, 2)); | ||
| } | ||
|
|
||
| @Test | ||
| void nullCounts_returnsFalse() { | ||
| assertFalse(PostgresCollection.isBatchFullySuccessful(null, 0)); | ||
| } | ||
| } | ||
|
|
||
| private void mockResultSetMetadata() throws SQLException { | ||
| when(mockResultSetMetaData.getColumnName(1)).thenReturn("quantity"); | ||
| when(mockResultSetMetaData.getColumnType(1)).thenReturn(INTEGER); | ||
|
|
||
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.
This might now break existing clients.
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.
Agreed it tightens the contract: callers that previously treated incomplete Mongo writes as success will now get
false/IOException. That is intentional for the gap we care about. Attribute-service is also adding a client-side post-write verify + evidence logs; we can stage rollout if needed.