@@ -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