Skip to content

Commit e1c550c

Browse files
authored
fix: add idempotency pre-checks for 4 DDL operators (#17)
Non-transactional DDL operators (createCollection, dropIndex, createView, renameCollection) now check MongoDB state before executing, making multi-step changes retry-safe. A new DatabaseInspector utility provides collection and index existence checks. 12 new integration tests cover idempotent skip paths and genuine conflict scenarios. ANALYSIS.md updated to reflect 9/11 operations now idempotent.
1 parent 52d15da commit e1c550c

10 files changed

Lines changed: 371 additions & 36 deletions

File tree

ANALYSIS.md

Lines changed: 35 additions & 35 deletions
Large diffs are not rendered by default.

src/main/java/io/flamingock/template/mongodb/model/operator/CreateCollectionOperator.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ public CreateCollectionOperator(MongoDatabase mongoDatabase, MongoOperation oper
2727

2828
@Override
2929
public void applyInternal(ClientSession clientSession) {
30+
if (DatabaseInspector.collectionExists(mongoDatabase, op.getCollection())) {
31+
logger.info("Collection '{}' already exists, skipping createCollection", op.getCollection());
32+
return;
33+
}
3034
mongoDatabase.createCollection(op.getCollection());
3135
}
3236

src/main/java/io/flamingock/template/mongodb/model/operator/CreateViewOperator.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ public CreateViewOperator(MongoDatabase mongoDatabase, MongoOperation operation)
3434

3535
@Override
3636
protected void applyInternal(ClientSession clientSession) {
37+
if (DatabaseInspector.collectionExists(mongoDatabase, op.getCollection())) {
38+
logger.info("View '{}' already exists, skipping createView", op.getCollection());
39+
return;
40+
}
3741
CreateViewOptions options = CreateViewOptionsMapper.map(op.getOptions());
3842
mongoDatabase.createView(op.getCollection(), getViewOn(), getPipeline(), options);
3943
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
* Copyright 2025 Flamingock (https://www.flamingock.io)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.flamingock.template.mongodb.model.operator;
17+
18+
import com.mongodb.client.MongoDatabase;
19+
import org.bson.Document;
20+
21+
import java.util.ArrayList;
22+
import java.util.List;
23+
24+
/**
25+
* Package-private utility for inspecting MongoDB state before operator execution.
26+
* Used by DDL operators to implement idempotent pre-checks.
27+
*/
28+
final class DatabaseInspector {
29+
30+
private DatabaseInspector() {
31+
}
32+
33+
static boolean collectionExists(MongoDatabase database, String collectionName) {
34+
return database.listCollectionNames()
35+
.into(new ArrayList<>())
36+
.contains(collectionName);
37+
}
38+
39+
static boolean indexExistsByName(MongoDatabase database, String collection, String indexName) {
40+
List<Document> indexes = database.getCollection(collection)
41+
.listIndexes()
42+
.into(new ArrayList<>());
43+
return indexes.stream().anyMatch(idx -> indexName.equals(idx.getString("name")));
44+
}
45+
46+
static boolean indexExistsByKeys(MongoDatabase database, String collection, Document keys) {
47+
List<Document> indexes = database.getCollection(collection)
48+
.listIndexes()
49+
.into(new ArrayList<>());
50+
return indexes.stream().anyMatch(idx -> {
51+
Document idxKeys = idx.get("key", Document.class);
52+
return idxKeys != null && idxKeys.equals(keys);
53+
});
54+
}
55+
}

src/main/java/io/flamingock/template/mongodb/model/operator/DropIndexOperator.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,16 @@ public DropIndexOperator(MongoDatabase mongoDatabase, MongoOperation operation)
2929
protected void applyInternal(ClientSession clientSession) {
3030
String indexName = getIndexName();
3131
if (indexName != null) {
32+
if (!DatabaseInspector.indexExistsByName(mongoDatabase, op.getCollection(), indexName)) {
33+
logger.info("Index '{}' does not exist on collection '{}', skipping dropIndex", indexName, op.getCollection());
34+
return;
35+
}
3236
mongoDatabase.getCollection(op.getCollection()).dropIndex(indexName);
3337
} else {
38+
if (!DatabaseInspector.indexExistsByKeys(mongoDatabase, op.getCollection(), op.getKeys())) {
39+
logger.info("Index with specified keys does not exist on collection '{}', skipping dropIndex", op.getCollection());
40+
return;
41+
}
3442
mongoDatabase.getCollection(op.getCollection()).dropIndex(op.getKeys());
3543
}
3644
}

src/main/java/io/flamingock/template/mongodb/model/operator/RenameCollectionOperator.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,16 @@ public RenameCollectionOperator(MongoDatabase mongoDatabase, MongoOperation oper
3030

3131
@Override
3232
protected void applyInternal(ClientSession clientSession) {
33-
MongoNamespace target = new MongoNamespace(mongoDatabase.getName(), getTarget());
33+
String targetName = getTarget();
34+
boolean sourceExists = DatabaseInspector.collectionExists(mongoDatabase, op.getCollection());
35+
boolean targetExists = DatabaseInspector.collectionExists(mongoDatabase, targetName);
36+
37+
if (!sourceExists && targetExists) {
38+
logger.info("Collection '{}' already renamed to '{}', skipping renameCollection", op.getCollection(), targetName);
39+
return;
40+
}
41+
42+
MongoNamespace target = new MongoNamespace(mongoDatabase.getName(), targetName);
3443
RenameCollectionOptions options = RenameCollectionOptionsMapper.map(op.getOptions());
3544
mongoDatabase.getCollection(op.getCollection()).renameCollection(target, options);
3645
}

src/test/java/io/flamingock/template/mongodb/operations/CreateCollectionOperatorTest.java

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,41 @@ void createCollectionTest() {
5252
assertTrue(collectionExists(COLLECTION_NAME), "Collection should exist after creation");
5353
}
5454

55+
@Test
56+
@DisplayName("WHEN createCollection operator is applied twice THEN second call succeeds silently")
57+
void createCollectionIdempotentTest() {
58+
assertFalse(collectionExists(COLLECTION_NAME), "Collection should not exist before creation");
59+
60+
MongoOperation operation = new MongoOperation();
61+
operation.setType("createCollection");
62+
operation.setCollection(COLLECTION_NAME);
63+
operation.setParameters(new HashMap<>());
64+
65+
CreateCollectionOperator operator = new CreateCollectionOperator(mongoDatabase, operation);
66+
operator.apply(null);
67+
assertTrue(collectionExists(COLLECTION_NAME), "Collection should exist after first creation");
68+
69+
// Second apply should not throw
70+
operator.apply(null);
71+
assertTrue(collectionExists(COLLECTION_NAME), "Collection should still exist after second creation");
72+
}
73+
74+
@Test
75+
@DisplayName("WHEN createCollection operator is applied and collection already exists THEN operation is skipped")
76+
void createCollectionAlreadyExistsTest() {
77+
mongoDatabase.createCollection(COLLECTION_NAME);
78+
assertTrue(collectionExists(COLLECTION_NAME), "Collection should exist before operator runs");
79+
80+
MongoOperation operation = new MongoOperation();
81+
operation.setType("createCollection");
82+
operation.setCollection(COLLECTION_NAME);
83+
operation.setParameters(new HashMap<>());
84+
85+
CreateCollectionOperator operator = new CreateCollectionOperator(mongoDatabase, operation);
86+
operator.apply(null);
87+
assertTrue(collectionExists(COLLECTION_NAME), "Collection should still exist after skipped creation");
88+
}
89+
5590
private boolean collectionExists(String collectionName) {
5691
return mongoDatabase.listCollectionNames()
5792
.into(new ArrayList<>())

src/test/java/io/flamingock/template/mongodb/operations/CreateViewOperatorTest.java

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import java.util.List;
2929
import java.util.Map;
3030

31+
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
3132
import static org.junit.jupiter.api.Assertions.assertEquals;
3233
import static org.junit.jupiter.api.Assertions.assertTrue;
3334

@@ -80,4 +81,69 @@ void createViewTest() {
8081
assertEquals(1, viewResults.size(), "View should return only active users");
8182
assertEquals("Active User", viewResults.get(0).getString("name"));
8283
}
84+
85+
@Test
86+
@DisplayName("WHEN createView operator is applied twice THEN second call succeeds silently and view still works")
87+
void createViewIdempotentTest() {
88+
mongoDatabase.createCollection(SOURCE_COLLECTION);
89+
mongoDatabase.getCollection(SOURCE_COLLECTION).insertMany(Arrays.asList(
90+
new Document("name", "Active User").append("status", "active"),
91+
new Document("name", "Inactive User").append("status", "inactive")
92+
));
93+
94+
MongoOperation operation = buildCreateViewOperation();
95+
CreateViewOperator operator = new CreateViewOperator(mongoDatabase, operation);
96+
operator.apply(null);
97+
98+
List<String> collections = mongoDatabase.listCollectionNames().into(new ArrayList<>());
99+
assertTrue(collections.contains(VIEW_NAME), "View should exist after first creation");
100+
101+
// Second apply should not throw
102+
assertDoesNotThrow(() -> operator.apply(null));
103+
104+
List<Document> viewResults = mongoDatabase.getCollection(VIEW_NAME)
105+
.find()
106+
.into(new ArrayList<>());
107+
assertEquals(1, viewResults.size(), "View should still return only active users");
108+
assertEquals("Active User", viewResults.get(0).getString("name"));
109+
}
110+
111+
@Test
112+
@DisplayName("WHEN createView operator is applied and view already exists THEN operation is skipped")
113+
void createViewAlreadyExistsTest() {
114+
mongoDatabase.createCollection(SOURCE_COLLECTION);
115+
116+
List<Document> pipeline = new ArrayList<>();
117+
pipeline.add(new Document("$match", new Document("status", "active")));
118+
mongoDatabase.createView(VIEW_NAME, SOURCE_COLLECTION, pipeline);
119+
assertTrue(mongoDatabase.listCollectionNames().into(new ArrayList<>()).contains(VIEW_NAME),
120+
"View should exist before operator runs");
121+
122+
MongoOperation operation = buildCreateViewOperation();
123+
CreateViewOperator operator = new CreateViewOperator(mongoDatabase, operation);
124+
assertDoesNotThrow(() -> operator.apply(null));
125+
126+
assertTrue(mongoDatabase.listCollectionNames().into(new ArrayList<>()).contains(VIEW_NAME),
127+
"View should still exist after skipped creation");
128+
}
129+
130+
private MongoOperation buildCreateViewOperation() {
131+
MongoOperation operation = new MongoOperation();
132+
operation.setType("createView");
133+
operation.setCollection(VIEW_NAME);
134+
135+
Map<String, Object> params = new HashMap<>();
136+
params.put("viewOn", SOURCE_COLLECTION);
137+
138+
List<Map<String, Object>> pipeline = new ArrayList<>();
139+
Map<String, Object> matchStage = new HashMap<>();
140+
Map<String, Object> matchQuery = new HashMap<>();
141+
matchQuery.put("status", "active");
142+
matchStage.put("$match", matchQuery);
143+
pipeline.add(matchStage);
144+
params.put("pipeline", pipeline);
145+
146+
operation.setParameters(params);
147+
return operation;
148+
}
83149
}

src/test/java/io/flamingock/template/mongodb/operations/DropIndexOperatorTest.java

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,91 @@ void dropIndexByKeysTest() {
8383
assertFalse(indexExistsByKeys("name"), "Index should have been dropped");
8484
}
8585

86+
@Test
87+
@DisplayName("WHEN dropIndex by name is applied twice THEN second call succeeds silently")
88+
void dropIndexByNameIdempotentTest() {
89+
mongoDatabase.createCollection(COLLECTION_NAME);
90+
mongoDatabase.getCollection(COLLECTION_NAME).createIndex(new Document("email", 1),
91+
new com.mongodb.client.model.IndexOptions().name(INDEX_NAME));
92+
assertTrue(indexExists(INDEX_NAME), "Index should exist before drop");
93+
94+
MongoOperation operation = new MongoOperation();
95+
operation.setType("dropIndex");
96+
operation.setCollection(COLLECTION_NAME);
97+
Map<String, Object> params = new HashMap<>();
98+
params.put("indexName", INDEX_NAME);
99+
operation.setParameters(params);
100+
101+
DropIndexOperator operator = new DropIndexOperator(mongoDatabase, operation);
102+
operator.apply(null);
103+
assertFalse(indexExists(INDEX_NAME), "Index should have been dropped");
104+
105+
// Second apply should not throw
106+
operator.apply(null);
107+
assertFalse(indexExists(INDEX_NAME), "Index should still not exist after second drop");
108+
}
109+
110+
@Test
111+
@DisplayName("WHEN dropIndex by keys is applied twice THEN second call succeeds silently")
112+
void dropIndexByKeysIdempotentTest() {
113+
mongoDatabase.createCollection(COLLECTION_NAME);
114+
mongoDatabase.getCollection(COLLECTION_NAME).createIndex(new Document("name", 1));
115+
assertTrue(indexExistsByKeys("name"), "Index should exist before drop");
116+
117+
MongoOperation operation = new MongoOperation();
118+
operation.setType("dropIndex");
119+
operation.setCollection(COLLECTION_NAME);
120+
Map<String, Object> params = new HashMap<>();
121+
Map<String, Object> keys = new HashMap<>();
122+
keys.put("name", 1);
123+
params.put("keys", keys);
124+
operation.setParameters(params);
125+
126+
DropIndexOperator operator = new DropIndexOperator(mongoDatabase, operation);
127+
operator.apply(null);
128+
assertFalse(indexExistsByKeys("name"), "Index should have been dropped");
129+
130+
// Second apply should not throw
131+
operator.apply(null);
132+
assertFalse(indexExistsByKeys("name"), "Index should still not exist after second drop");
133+
}
134+
135+
@Test
136+
@DisplayName("WHEN dropIndex by name targets non-existent index THEN operation is skipped without error")
137+
void dropIndexByNameNonExistentTest() {
138+
mongoDatabase.createCollection(COLLECTION_NAME);
139+
140+
MongoOperation operation = new MongoOperation();
141+
operation.setType("dropIndex");
142+
operation.setCollection(COLLECTION_NAME);
143+
Map<String, Object> params = new HashMap<>();
144+
params.put("indexName", "nonExistentIndex");
145+
operation.setParameters(params);
146+
147+
DropIndexOperator operator = new DropIndexOperator(mongoDatabase, operation);
148+
operator.apply(null);
149+
// Should complete without throwing
150+
}
151+
152+
@Test
153+
@DisplayName("WHEN dropIndex by keys targets non-existent index THEN operation is skipped without error")
154+
void dropIndexByKeysNonExistentTest() {
155+
mongoDatabase.createCollection(COLLECTION_NAME);
156+
157+
MongoOperation operation = new MongoOperation();
158+
operation.setType("dropIndex");
159+
operation.setCollection(COLLECTION_NAME);
160+
Map<String, Object> params = new HashMap<>();
161+
Map<String, Object> keys = new HashMap<>();
162+
keys.put("nonExistentField", 1);
163+
params.put("keys", keys);
164+
operation.setParameters(params);
165+
166+
DropIndexOperator operator = new DropIndexOperator(mongoDatabase, operation);
167+
operator.apply(null);
168+
// Should complete without throwing
169+
}
170+
86171
private boolean indexExists(String indexName) {
87172
List<Document> indexes = mongoDatabase.getCollection(COLLECTION_NAME)
88173
.listIndexes()

0 commit comments

Comments
 (0)