Skip to content

Commit f61d8bd

Browse files
author
Philip Z
committed
[skip ci] Integrate approved post-commit reconciliation into audit fix
2 parents 9389407 + 092e483 commit f61d8bd

6 files changed

Lines changed: 171 additions & 12 deletions

File tree

‎teaql-core/src/main/java/io/teaql/core/BaseEntity.java‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,7 @@ public EntityMutationLedger getEntityMutationLedger() {
427427
public boolean __internalHasMutationLedger() { return entityMutationLedger != null; }
428428

429429
public void setEntityMutationLedger(EntityMutationLedger entityMutationLedger) {
430+
if (this.entityMutationLedger != null) this.entityMutationLedger.requireSaveable();
430431
this.entityMutationLedger = entityMutationLedger;
431432
if (entityMutationLedger != null && id != null && newItem()) {
432433
entityMutationLedger.markAsNew(new EntityKey(typeName(), id));

‎teaql-core/src/main/java/io/teaql/core/EntityMutationLedger.java‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ public class EntityMutationLedger {
1616
private final Set<EntityKey> newKeys = new TreeSet<>();
1717
private final Map<EntityKey, List<TraceNode>> traceChains = new TreeMap<>();
1818
private final Map<EntityKey, Long> originalVersions = new TreeMap<>();
19+
private boolean committedStateRequiresReload;
1920

2021
// --- Change Set Stack ---
2122

@@ -41,6 +42,19 @@ public void clearCurrentChangeSet() {
4142
traceChains.clear();
4243
}
4344

45+
/** A provider write completed, but the in-memory graph could not be reconciled. */
46+
public void markCommittedStateRequiresReload() {
47+
committedStateRequiresReload = true;
48+
}
49+
50+
/** Prevents a stale graph from replaying a write whose outcome is already committed. */
51+
public void requireSaveable() {
52+
if (committedStateRequiresReload) {
53+
throw new TeaQLRuntimeException(
54+
"COMMITTED_STATE_RELOAD_REQUIRED: reload the entity graph before another save");
55+
}
56+
}
57+
4458
public void set(EntityKey key, String field, Object value) {
4559
changeSets.set(key, field, value);
4660
}
@@ -141,12 +155,14 @@ public Long getOriginalVersion(EntityKey key) {
141155
* for a read-only entity, which must retain its private ledger ownership.
142156
*/
143157
public boolean mergeEntityFrom(EntityMutationLedger other, EntityKey key) {
158+
requireSaveable();
144159
if (other == null || other == this) return false;
145160
Map<String, Object> fields = other.currentChangeSet().changes().get(key);
146161
var dynamic = other.currentChangeSet().dynamicChanges().get(key);
147162
boolean pending = (fields != null && !fields.isEmpty()) || other.newKeys.contains(key)
148163
|| other.deletedKeys.contains(key) || other.recoveredKeys.contains(key) || (dynamic != null && !dynamic.isEmpty());
149164
if (!pending) return false;
165+
other.requireSaveable();
150166
if (fields != null) fields.forEach((field, value) -> set(key, field, value));
151167
if (dynamic != null) dynamic.values().forEach(mutation -> setDynamic(key, mutation));
152168
if (other.deletedKeys.contains(key)) markAsDelete(key);
@@ -162,7 +178,9 @@ public boolean mergeEntityFrom(EntityMutationLedger other, EntityKey key) {
162178
* Used when saving an entity graph (e.g., Order + OrderItems).
163179
*/
164180
public void mergeFrom(EntityMutationLedger other) {
181+
requireSaveable();
165182
if (other == null) return;
183+
other.requireSaveable();
166184

167185
// Merge change sets
168186
EntityChangeSet otherChangeSet = other.currentChangeSet();

‎teaql-core/src/test/java/io/teaql/core/EntityMutationLedgerTest.java‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,23 @@ public class EntityMutationLedgerTest {
1313
private static final EntityKey ORDER = new EntityKey("Order", 1L);
1414
private static final EntityKey OTHER_ORDER = new EntityKey("Order", 2L);
1515

16+
@Test
17+
public void committedReconciliationFailureCannotBeClearedOrMergedIntoAnotherLedger() {
18+
EntityMutationLedger failed = new EntityMutationLedger();
19+
failed.markAsNew(ORDER);
20+
failed.markCommittedStateRequiresReload();
21+
assertThrows(TeaQLRuntimeException.class, failed::requireSaveable);
22+
23+
EntityMutationLedger independent = new EntityMutationLedger();
24+
assertThrows(TeaQLRuntimeException.class,
25+
() -> independent.mergeEntityFrom(failed, ORDER));
26+
assertThrows(TeaQLRuntimeException.class, () -> independent.mergeFrom(failed));
27+
28+
failed.clearCurrentChangeSet();
29+
assertThrows(TeaQLRuntimeException.class, failed::requireSaveable);
30+
independent.requireSaveable();
31+
}
32+
1633
@Test
1734
public void markAsNewRecordsOnlyRequestedKeyAndIsIdempotent() {
1835
EntityMutationLedger root = new EntityMutationLedger();
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package io.teaql.runtime;
2+
3+
import io.teaql.core.TeaQLRuntimeException;
4+
5+
/** The provider write completed, but the in-memory graph requires a fresh load. */
6+
public final class CommittedStateReconciliationException extends TeaQLRuntimeException {
7+
public CommittedStateReconciliationException() {
8+
super("COMMITTED_STATE_RELOAD_REQUIRED: provider write completed but entity reconciliation failed; "
9+
+ "reload the graph and do not retry this instance");
10+
}
11+
}

‎teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java‎

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -457,9 +457,10 @@ public void saveGraph(UserContext context, Entity entity) {
457457
"teaql.mutation.kind", "save")));
458458
try {
459459
MutationIntent intent = MutationIntent.of(entity.getComment());
460-
checkAndFix(context, entity);
461460
// Get entity's own EntityMutationLedger
462461
EntityMutationLedger entityMutationLedger = ((BaseEntity) entity).getEntityMutationLedger();
462+
entityMutationLedger.requireSaveable();
463+
checkAndFix(context, entity);
463464

464465
// Allocate identifiers before roots are merged. New children commonly
465466
// receive their field updates while their id is still null, so those
@@ -573,13 +574,30 @@ public void saveGraph(UserContext context, Entity entity) {
573574
Collections.newSetFromMap(new IdentityHashMap<>()));
574575
throw failure;
575576
}
576-
dynamicReadbacks.forEach((key, values) -> {
577-
BaseEntity target = realEntities.get(key);
578-
target.__internalHydrateDynamicFields(values, target.__internalLoadState().withDynamicSelection(values.selectedCodes()));
579-
});
580-
completeLedgerPlan(completed);
581-
entityMutationLedger.clearCurrentChangeSet();
582-
deliverCommittedAuditEvents(context, completed);
577+
CommittedStateReconciliationException reconciliationOutcome = null;
578+
try {
579+
dynamicReadbacks.forEach((key, values) -> {
580+
BaseEntity target = realEntities.get(key);
581+
target.__internalHydrateDynamicFields(values, target.__internalLoadState().withDynamicSelection(values.selectedCodes()));
582+
});
583+
completeLedgerPlan(completed);
584+
entityMutationLedger.clearCurrentChangeSet();
585+
} catch (RuntimeException reconciliationFailure) {
586+
// The provider has returned from its transaction. The graph may be
587+
// partially reconciled, so neither its pending ledger nor its
588+
// entity instances are safe to save again without a fresh load.
589+
entityMutationLedger.markCommittedStateRequiresReload();
590+
reconciliationOutcome = new CommittedStateReconciliationException();
591+
}
592+
try {
593+
// The provider has already committed. A failed in-memory readback
594+
// must not silently suppress the independent audit delivery.
595+
deliverCommittedAuditEvents(context, completed);
596+
} catch (AuditDeliveryAfterCommitException auditFailure) {
597+
if (reconciliationOutcome == null) throw auditFailure;
598+
reconciliationOutcome.addSuppressed(auditFailure);
599+
}
600+
if (reconciliationOutcome != null) throw reconciliationOutcome;
583601
telemetryScope.success();
584602
} catch (RuntimeException | Error error) {
585603
telemetryScope.failure(error);
@@ -1116,7 +1134,8 @@ private void deliverCommittedAuditEvents(UserContext context, List<PendingMutati
11161134
try {
11171135
failures += emitAuditEvent(
11181136
context, mutation.target(), mutation.auditKind(), mutation.changedValues(),
1119-
mutation.governance(), mutation.intent(), mutation.traceChain(), mutation.redactions());
1137+
mutation.governance(), mutation.intent(), mutation.traceChain(), mutation.redactions(),
1138+
mutation.result().persistedEntity().getVersion());
11201139
} catch (RuntimeException failure) {
11211140
// The provider transaction has already committed. Continue delivery
11221141
// for the other graph members and never expose a retryable save error.
@@ -1433,7 +1452,7 @@ private int emitAuditEvent(
14331452
MutationAuditKind kind,
14341453
Map<String, Object> changedValues,
14351454
MutationGovernanceSnapshot governance, MutationIntent intent, List<TraceNode> traceChain,
1436-
SqlIntentRedactions redactions) {
1455+
SqlIntentRedactions redactions, Long committedVersion) {
14371456
List<AuditFieldChange> changes = new ArrayList<>();
14381457
if (changedValues != null) {
14391458
for (Map.Entry<String, Object> entry : changedValues.entrySet()) {
@@ -1463,7 +1482,7 @@ private int emitAuditEvent(
14631482
context.getAttribute(GeneratedSchemaBootstrap.AUDIT_ACTOR_ATTRIBUTE, String.class),
14641483
context.getAttribute(GeneratedSchemaBootstrap.AUDIT_CATEGORY_ATTRIBUTE, String.class),
14651484
intent.auditReason(),
1466-
entity.getVersion(),
1485+
committedVersion,
14671486
java.time.Instant.now(),
14681487
governance);
14691488

‎teaql-sqlite/src/test/java/io/teaql/sqlite/GraphTraceSqliteTest.java‎

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ public class GraphTraceSqliteTest {
3030
public static final class GraphEntity extends BaseEntity {
3131
private final String type;
3232
private final Map<String, Object> values = new HashMap<>();
33+
boolean failPostCommitReconcile;
3334
public GraphEntity(String type) { this.type = type; }
3435
@Override public String typeName() { return type; }
3536
@Override public Object __internalGet(String field) {
@@ -40,6 +41,9 @@ public static final class GraphEntity extends BaseEntity {
4041
? values.get(field) : super.__internalGet(field);
4142
}
4243
@Override public void __internalSet(String field, Object value) {
44+
if (failPostCommitReconcile && field.equals("version")) {
45+
throw new IllegalStateException("diagnostic reconciliation failure");
46+
}
4347
if (field.equals("name") || field.equals("memo") || field.equals("children")
4448
|| field.equals("business_counter") || field.equals("planned_date")
4549
|| field.equals("active") || field.equals("ratio") || field.equals("optional_note")
@@ -51,6 +55,7 @@ public static final class GraphEntity extends BaseEntity {
5155
static final class Fixture {
5256
final List<ExecutionMetadata> sql = new CopyOnWriteArrayList<>();
5357
final List<SafeAuditEvent> audit = new CopyOnWriteArrayList<>();
58+
final List<RawAuditEvent> rawAudit = new CopyOnWriteArrayList<>();
5459
final List<EntityPersistenceMutation> commands = new CopyOnWriteArrayList<>();
5560
final List<MutationResult> results = new CopyOnWriteArrayList<>();
5661
boolean queryLogging = true;
@@ -170,6 +175,7 @@ static final class Fixture {
170175

171176
@Override public void writeAuditEvent(UserContext caller, RawAuditEvent event) {
172177
if (failStandardAudit) throw new IllegalStateException("standard audit sink failure");
178+
rawAudit.add(event);
173179
}
174180
});
175181
if (committedRecorder != null) builder.committedMutationRecorder(committedRecorder);
@@ -184,7 +190,7 @@ static final class Fixture {
184190
}
185191

186192
void clear() {
187-
sql.clear(); audit.clear(); commands.clear(); results.clear(); itemInsertBatchSizes.clear();
193+
sql.clear(); audit.clear(); rawAudit.clear(); commands.clear(); results.clear(); itemInsertBatchSizes.clear();
188194
itemUpdateBatchSizes.clear(); itemDeleteBatchSizes.clear(); itemRecoverBatchSizes.clear();
189195
}
190196

@@ -721,6 +727,93 @@ private static CheckedSave checkedSave(Fixture fixture, GraphEntity entity, Stri
721727
.outboxAfter("java-graph-source", 0, 10).size());
722728
}
723729

730+
@Test public void postCommitReconciliationFailureMustReportCommittedOutcome() throws Exception {
731+
var fixture = new Fixture(true);
732+
var order = fixture.create("CustomerOrder", 196, "reconcile after commit");
733+
order.failPostCommitReconcile = true;
734+
735+
var failure = assertThrows(CommittedStateReconciliationException.class,
736+
() -> order.auditAs("diagnose committed reconciliation failure").save(fixture.context));
737+
assertEquals(1, fixture.driver.queryForList(
738+
"SELECT id FROM customer_order_data WHERE id = ?", new Object[] {196L}).size());
739+
assertEquals(1, fixture.committedRecorder.eventStore()
740+
.outboxAfter("java-graph-source", 0, 10).size());
741+
assertEquals("committed mutations still require application audit delivery", 1, fixture.audit.size());
742+
assertEquals(1, fixture.rawAudit.size());
743+
assertEquals(Long.valueOf(1), fixture.rawAudit.get(0).resultingVersion());
744+
assertTrue("committed outcome must be explicit to prevent blind retry",
745+
failure.getMessage().contains("provider write completed"));
746+
order.failPostCommitReconcile = false;
747+
var retryFailure = assertThrows(TeaQLRuntimeException.class,
748+
() -> order.auditAs("retry committed reconciliation failure").save(fixture.context));
749+
assertTrue(retryFailure.getMessage().contains("COMMITTED_STATE_RELOAD_REQUIRED"));
750+
assertThrows(TeaQLRuntimeException.class,
751+
() -> order.setEntityMutationLedger(new EntityMutationLedger()));
752+
assertEquals(1, fixture.committedRecorder.eventStore()
753+
.outboxAfter("java-graph-source", 0, 10).size());
754+
assertEquals(1, fixture.audit.size());
755+
var independent = fixture.create("Payment", 195, "unrelated graph may still save");
756+
independent.auditAs("save independent graph after failure").save(fixture.context);
757+
assertEquals(2, fixture.committedRecorder.eventStore()
758+
.outboxAfter("java-graph-source", 0, 10).size());
759+
}
760+
761+
@Test public void postCommitChildReconciliationFailurePoisonsOnlyItsSaveGraph() throws Exception {
762+
var fixture = new Fixture(true);
763+
var order = fixture.create("CustomerOrder", 194, "root already committed");
764+
var item = fixture.create("OrderItem", 294, "child needs reload");
765+
item.failPostCommitReconcile = true;
766+
order.__internalSet("children", List.of(item));
767+
768+
assertThrows(CommittedStateReconciliationException.class,
769+
() -> order.auditAs("commit graph before child reconciliation").save(fixture.context));
770+
assertEquals(1, fixture.driver.queryForList(
771+
"SELECT id FROM customer_order_data WHERE id = ?", new Object[] {194L}).size());
772+
assertEquals(1, fixture.driver.queryForList(
773+
"SELECT id FROM order_item_data WHERE id = ?", new Object[] {294L}).size());
774+
assertEquals(1, fixture.committedRecorder.eventStore()
775+
.outboxAfter("java-graph-source", 0, 10).size());
776+
777+
item.failPostCommitReconcile = false;
778+
assertThrows(TeaQLRuntimeException.class,
779+
() -> item.auditAs("unsafe child retry").save(fixture.context));
780+
assertThrows(TeaQLRuntimeException.class,
781+
() -> order.auditAs("unsafe root retry").save(fixture.context));
782+
assertEquals(1, fixture.committedRecorder.eventStore()
783+
.outboxAfter("java-graph-source", 0, 10).size());
784+
assertEquals("both committed graph members must still be audited", 2, fixture.audit.size());
785+
assertEquals(2, fixture.rawAudit.size());
786+
}
787+
788+
@Test public void postCommitReconciliationAndAuditFailureRetainBothOutcomes() throws Exception {
789+
var fixture = new Fixture(true);
790+
var attempts = new java.util.concurrent.atomic.AtomicInteger();
791+
fixture.context.putAttribute(AppAuditEventSink.class.getName(),
792+
(AppAuditEventSink) (caller, event) -> {
793+
attempts.incrementAndGet();
794+
throw new IllegalStateException("diagnostic audit sink failure");
795+
});
796+
var order = fixture.create("CustomerOrder", 193, "committed despite two post-commit failures");
797+
order.failPostCommitReconcile = true;
798+
799+
var failure = assertThrows(CommittedStateReconciliationException.class,
800+
() -> order.auditAs("commit before reconciliation and audit failures").save(fixture.context));
801+
assertEquals(1, attempts.get());
802+
assertEquals(1, failure.getSuppressed().length);
803+
assertTrue(failure.getSuppressed()[0] instanceof AuditDeliveryAfterCommitException);
804+
assertEquals(1, fixture.rawAudit.size());
805+
assertEquals(Long.valueOf(1), fixture.rawAudit.get(0).resultingVersion());
806+
assertEquals(1, fixture.driver.queryForList(
807+
"SELECT id FROM customer_order_data WHERE id = ?", new Object[] {193L}).size());
808+
assertEquals(1, fixture.committedRecorder.eventStore()
809+
.outboxAfter("java-graph-source", 0, 10).size());
810+
order.failPostCommitReconcile = false;
811+
assertThrows(TeaQLRuntimeException.class,
812+
() -> order.auditAs("reject retry after two post-commit failures").save(fixture.context));
813+
assertEquals(1, fixture.committedRecorder.eventStore()
814+
.outboxAfter("java-graph-source", 0, 10).size());
815+
}
816+
724817
@Test public void postCommitAuditFailureStillAttemptsEveryGraphMember() throws Exception {
725818
var fixture = new Fixture(true);
726819
var attempts = new java.util.concurrent.atomic.AtomicInteger();

0 commit comments

Comments
 (0)