Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ import org.eclipse.dirigible.sdk.utils.Json;
* - binds the source's #if($isCreate)create (bare entity)#{else}-transitioned#end topic and RE-LOADS the source by
* id (the payload is as-of the event and may lack later-step data);
* - idempotent by ${backRef}: if ${into} rows already back-reference this source, it is a no-op;
* - all writes go through the generated ${into} repository (so its own create logic fires).
* - all writes go through the generated ${into} repository (so its own create logic fires), and the
* rows one source event derives are ONE transaction, so the back-reference either finds the whole
* post or finds nothing at all - a tick that fails part-way leaves nothing behind to be mistaken
* for a finished post (dirigible #7179).
*/
@Component("${javaGenFolderName}_${className}Post")
public class ${className}Post implements MessageHandler {
Expand Down Expand Up @@ -55,14 +58,19 @@ public class ${className}Post implements MessageHandler {
gen.${javaGenFolderName}.data.${targetJavaPerspective}.${into}Repository targetRepository =
new gen.${javaGenFolderName}.data.${targetJavaPerspective}.${into}Repository();
#if($backRef != "")
// Idempotency: a row already back-referencing this source means the post ran - no-op.
// Idempotency: a row already back-referencing this source means the post ran - no-op. Mere
// EXISTENCE is an exact test only because the rows below are written as one transaction: a
// failed tick commits none of them, so the back-reference is present for a whole post and
// absent for no post - there is no partial set for this guard to read as finished (#7179).
if (!targetRepository.findAll(Criteria.create().eq("${backRef}", source.${sourceKeyField})).isEmpty()) {
return;
}
#end
#if($perItem)
gen.${javaGenFolderName}.data.${itemsJavaPerspective}.${itemsEntity}Repository itemsRepository =
new gen.${javaGenFolderName}.data.${itemsJavaPerspective}.${itemsEntity}Repository();
// Derive every row first - reads and in-memory mapping only, nothing written yet.
java.util.List<gen.${javaGenFolderName}.data.${targetJavaPerspective}.${into}Entity> rows = new java.util.ArrayList<>();
for (gen.${javaGenFolderName}.data.${itemsJavaPerspective}.${itemsEntity}Entity item :
itemsRepository.findAll(Criteria.create().eq("${itemsFk}", source.${sourceKeyField}))) {
gen.${javaGenFolderName}.data.${targetJavaPerspective}.${into}Entity row =
Expand All @@ -73,8 +81,19 @@ public class ${className}Post implements MessageHandler {
#if($backRef != "")
row.${backRef} = source.${sourceKeyField};
#end
targetRepository.save(row);
rows.add(row);
}
// ONE transaction for every row this source event derives. Written one save per transaction, a
// row the repository refused (a validation, a constraint) left the earlier rows durable - and
// since the guard above reads the back-reference as mere existence, every redelivery afterwards
// was a no-op and the missing rows were never written: the half-post was PERMANENT
// (dirigible #7179). All of them commit or none does, so the redelivery finds nothing and
// writes the whole set.
org.eclipse.dirigible.components.data.store.java.repository.UnitOfWork.run(() -> {
for (gen.${javaGenFolderName}.data.${targetJavaPerspective}.${into}Entity row : rows) {
targetRepository.save(row);
}
});
#else
gen.${javaGenFolderName}.data.${targetJavaPerspective}.${into}Entity row =
new gen.${javaGenFolderName}.data.${targetJavaPerspective}.${into}Entity();
Expand All @@ -84,6 +103,7 @@ public class ${className}Post implements MessageHandler {
#if($backRef != "")
row.${backRef} = source.${sourceKeyField};
#end
// One row, one repository call - a transaction on its own, so no unit of work is needed here.
targetRepository.save(row);
#end
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3348,6 +3348,104 @@ void postings_generates_the_idempotent_resumable_handler() {
assertFalse(posting.contains("was NOT rewritten"), "a target with no status lifecycle is always rewritable");
}

@Test
void posts_writes_every_row_of_one_source_event_in_one_transaction() {
// #7179: the FLAT per-item mode (posts:, no header document) had the same multi-write shape as
// the posting rewrite and no unit of work - one save per row, one transaction each. A row the
// repository refused left the rows before it durable, and the guard here is coarser than the
// posting's: it asks whether ANY row back-references this source, so the partial set read as a
// finished post and no redelivery ever wrote the rest. The half-post was PERMANENT. The rows
// are derived first and written together, so the guard sees a whole post or nothing.
String yaml = """
name: poststest
entities:
- name: GoodsIssueStatus
kind: setting
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: name, type: string, required: true, length: 100 }
- name: Product
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: name, type: string, required: true, length: 100 }
- name: GoodsIssue
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: number, type: string, length: 40 }
relations:
- { name: Status, kind: manyToOne, to: GoodsIssueStatus, function: EntityStatus, init: 1 }
- name: GoodsIssueItem
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: quantity, type: decimal, precision: 18, scale: 3 }
relations:
- { name: GoodsIssue, kind: manyToOne, to: GoodsIssue, composition: true, required: true }
- { name: Product, kind: manyToOne, to: Product }
- name: StockMovement
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: quantity, type: decimal, precision: 18, scale: 3 }
relations:
- { name: Product, kind: manyToOne, to: Product }
- { name: GoodsIssue, kind: manyToOne, to: GoodsIssue }
- name: StockNote
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: note, type: string, length: 100 }
relations:
- { name: GoodsIssue, kind: manyToOne, to: GoodsIssue }
posts:
- name: goodsIssueLedger
forEntity: GoodsIssue
event: 2
forEach: items
into: StockMovement
idempotentBy: GoodsIssue
set:
Product: item.Product
Quantity: "-item.Quantity"
- name: goodsIssueNote
forEntity: GoodsIssue
event: create
into: StockNote
idempotentBy: GoodsIssue
set:
Note: source.Number
""";
writeIntent(yaml);
restAssuredExecutor.execute(() -> given().when()
.post(GENERATE_URL)
.then()
.statusCode(200));

String glue = contentOf("poststest.glue");
assertTrue(glue.contains("\"posts\""), "the .glue should carry the posts collection");
assertTrue(glue.contains("GoodsIssueLedger"), "the post className should be carried in the glue");

generateFromModel("template-application-events-java/template/template.js", "poststest.glue");
String post = codeOf("gen/events/poststest/GoodsIssueLedgerPost.java");
assertTrue(post.contains("implements MessageHandler"), "the post is a self-describing message handler");
assertTrue(post.contains("-transitioned"), "a status-triggered post listens on the source's -transitioned channel");
assertTrue(post.contains("Criteria.create().eq(\"GoodsIssue\", source.Id)"), "the guard asks the back-reference on the target");
// Asserted by POSITION, since a save left inside the derivation loop would still "mention
// UnitOfWork": every row is mapped in memory first, and the ONE save site sits inside the block.
int derived = post.indexOf("rows.add(row)");
int unitOfWork = post.indexOf("UnitOfWork.run(() -> {");
int save = post.indexOf("targetRepository.save(row)");
assertTrue(derived > 0, "the rows must be derived into a list before anything is written");
assertTrue(unitOfWork > derived, "the unit of work must open after the derivation, not around the reads");
assertTrue(save > unitOfWork, "every row must be saved inside the unit of work");
assertEquals(save, post.lastIndexOf("targetRepository.save(row)"),
"there must be exactly ONE save site - a second one outside the block would write rows unprotected");
assertFalse(post.contains("${"), "the post template must render every placeholder");

// The single-row mode (no forEach) writes one row through one repository call - a transaction on
// its own, so it needs no unit of work and must not pretend to open one.
String single = codeOf("gen/events/poststest/GoodsIssueNotePost.java");
assertTrue(single.contains("targetRepository.save(row)"), "the single-row post writes its one row");
assertFalse(single.contains("UnitOfWork"), "one repository call is already one transaction");
}

@Test
void a_post_is_not_rewritten_once_the_created_document_has_left_the_status_it_was_created_in() {
// #7071: an amended source (rejected, edited, re-issued) raises the SAME moment again, and the
Expand Down
Loading
Loading