Skip to content

Commit 9389407

Browse files
author
Philip Z
committed
fix: distinguish post-commit audit delivery failures
1 parent 54d0f11 commit 9389407

3 files changed

Lines changed: 136 additions & 20 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package io.teaql.runtime;
2+
3+
import io.teaql.core.TeaQLRuntimeException;
4+
5+
/** The graph committed, but one or more post-commit audit deliveries failed. */
6+
public final class AuditDeliveryAfterCommitException extends TeaQLRuntimeException {
7+
private final int failureCount;
8+
9+
public AuditDeliveryAfterCommitException(int failureCount) {
10+
super("AUDIT_DELIVERY_AFTER_COMMIT: transaction committed but audit delivery failed; "
11+
+ "do not retry the mutation blindly");
12+
if (failureCount < 1) throw new IllegalArgumentException("failureCount must be positive");
13+
this.failureCount = failureCount;
14+
}
15+
16+
public int failureCount() {
17+
return failureCount;
18+
}
19+
}

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

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -577,8 +577,9 @@ public void saveGraph(UserContext context, Entity entity) {
577577
BaseEntity target = realEntities.get(key);
578578
target.__internalHydrateDynamicFields(values, target.__internalLoadState().withDynamicSelection(values.selectedCodes()));
579579
});
580-
completeLedgerPlan(context, completed);
580+
completeLedgerPlan(completed);
581581
entityMutationLedger.clearCurrentChangeSet();
582+
deliverCommittedAuditEvents(context, completed);
582583
telemetryScope.success();
583584
} catch (RuntimeException | Error error) {
584585
telemetryScope.failure(error);
@@ -1102,18 +1103,33 @@ private List<PendingMutation> executeLedgerPlan(
11021103
return completed;
11031104
}
11041105

1105-
private void completeLedgerPlan(UserContext context, List<PendingMutation> completed) {
1106+
private void completeLedgerPlan(List<PendingMutation> completed) {
11061107
for (PendingMutation mutation : completed) {
11071108
applyPersistedEntity(mutation.descriptor(), mutation.target(), mutation.result());
1108-
emitAuditEvent(
1109-
context, mutation.target(), mutation.auditKind(), mutation.changedValues(),
1110-
mutation.governance(), mutation.intent(), mutation.traceChain(), mutation.redactions());
11111109
mutation.target().clearUpdatedProperties();
1112-
// A committed delete supersedes any staged extension edits too.
1113-
mutation.target().__internalClearDynamicMutations();
11141110
}
11151111
}
11161112

1113+
private void deliverCommittedAuditEvents(UserContext context, List<PendingMutation> completed) {
1114+
int failures = 0;
1115+
for (PendingMutation mutation : completed) {
1116+
try {
1117+
failures += emitAuditEvent(
1118+
context, mutation.target(), mutation.auditKind(), mutation.changedValues(),
1119+
mutation.governance(), mutation.intent(), mutation.traceChain(), mutation.redactions());
1120+
} catch (RuntimeException failure) {
1121+
// The provider transaction has already committed. Continue delivery
1122+
// for the other graph members and never expose a retryable save error.
1123+
failures++;
1124+
} finally {
1125+
// Preserve dynamic original values until the audit projection has
1126+
// observed them; then clear staged extension edits even on failure.
1127+
mutation.target().__internalClearDynamicMutations();
1128+
}
1129+
}
1130+
if (failures != 0) throw new AuditDeliveryAfterCommitException(failures);
1131+
}
1132+
11171133
private Map<String, Object> snapshotChanges(Map<String, Object> changes) {
11181134
return Collections.unmodifiableMap(new LinkedHashMap<>(changes));
11191135
}
@@ -1411,7 +1427,7 @@ private List<MutationResult> mutateBatchWithTelemetry(
14111427
}
14121428
}
14131429

1414-
private void emitAuditEvent(
1430+
private int emitAuditEvent(
14151431
UserContext context,
14161432
Entity entity,
14171433
MutationAuditKind kind,
@@ -1457,18 +1473,29 @@ private void emitAuditEvent(
14571473
"teaql.mutation.kind", kind.name().toLowerCase(Locale.ROOT),
14581474
"teaql.audit.changed_field_count", changes.size())));
14591475
try {
1476+
int failures = 0;
1477+
// The server-owned and application sinks are independent delivery
1478+
// attempts. One failure must not suppress the other after commit.
1479+
if (logSink != null) {
1480+
try {
1481+
logSink.writeAuditEvent(context,
1482+
LogPrivacy.audit(rawEvent, LogPrivacy.plaintextEnabled(), redactions));
1483+
} catch (RuntimeException failure) {
1484+
failures++;
1485+
}
1486+
}
14601487

1461-
// The standard sink is server-owned by TeaQLRuntime and cannot be replaced by
1462-
// dynamic input or an application capability registered on UserContext.
1463-
if (logSink != null) {
1464-
logSink.writeAuditEvent(context, LogPrivacy.audit(rawEvent, LogPrivacy.plaintextEnabled(), redactions));
1465-
}
1466-
1467-
AppAuditEventSink appSink = context.capability(AppAuditEventSink.class);
1468-
if (appSink != null) {
1469-
appSink.onAuditEvent(context, buildSafeAuditEvent(rawEvent, redactions));
1470-
}
1471-
telemetryScope.success();
1488+
AppAuditEventSink appSink = context.capability(AppAuditEventSink.class);
1489+
if (appSink != null) {
1490+
try {
1491+
appSink.onAuditEvent(context, buildSafeAuditEvent(rawEvent, redactions));
1492+
} catch (RuntimeException failure) {
1493+
failures++;
1494+
}
1495+
}
1496+
if (failures == 0) telemetryScope.success();
1497+
else telemetryScope.failure(new AuditDeliveryAfterCommitException(failures));
1498+
return failures;
14721499
} catch (RuntimeException | Error error) {
14731500
telemetryScope.failure(error);
14741501
throw error;

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

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ static final class Fixture {
5555
final List<MutationResult> results = new CopyOnWriteArrayList<>();
5656
boolean queryLogging = true;
5757
boolean mutationLogging = true;
58+
boolean failStandardAudit;
5859
final List<Integer> itemInsertBatchSizes = new CopyOnWriteArrayList<>();
5960
final List<Integer> itemUpdateBatchSizes = new CopyOnWriteArrayList<>();
6061
final List<Integer> itemDeleteBatchSizes = new CopyOnWriteArrayList<>();
@@ -162,7 +163,15 @@ static final class Fixture {
162163
var ids = new AtomicLong(1000);
163164
var builder = TeaQLRuntime.builder().metadata(metadata).dataService("sqlite", provider)
164165
.idGenerationService((caller, entity) -> ids.getAndIncrement())
165-
.logSink((caller, entry) -> sql.add(entry));
166+
.logSink(new RuntimeLogSink() {
167+
@Override public void writeExecutionLog(UserContext caller, ExecutionMetadata entry) {
168+
sql.add(entry);
169+
}
170+
171+
@Override public void writeAuditEvent(UserContext caller, RawAuditEvent event) {
172+
if (failStandardAudit) throw new IllegalStateException("standard audit sink failure");
173+
}
174+
});
166175
if (committedRecorder != null) builder.committedMutationRecorder(committedRecorder);
167176
var runtime = builder.build();
168177
context = new DefaultUserContext(runtime) {
@@ -688,6 +697,67 @@ private static CheckedSave checkedSave(Fixture fixture, GraphEntity entity, Stri
688697
assertTrue("no post-commit audit on rolled back save", fixture.audit.isEmpty());
689698
}
690699

700+
@Test public void postCommitAuditFailureReportsCommittedOutcomeWithoutReplayingLedger() throws Exception {
701+
var fixture = new Fixture(true);
702+
fixture.context.putAttribute(AppAuditEventSink.class.getName(),
703+
(AppAuditEventSink) (caller, event) -> {
704+
throw new IllegalStateException("diagnostic audit sink failure");
705+
});
706+
var order = fixture.create("CustomerOrder", 199, "committed before audit delivery");
707+
708+
var failure = assertThrows(AuditDeliveryAfterCommitException.class,
709+
() -> order.auditAs("diagnose post-commit audit failure").save(fixture.context));
710+
assertTrue(failure.getMessage().contains("AUDIT_DELIVERY_AFTER_COMMIT"));
711+
assertEquals(1, failure.failureCount());
712+
assertEquals(1, fixture.driver.queryForList(
713+
"SELECT id FROM customer_order_data WHERE id = ?", new Object[] {199L}).size());
714+
assertEquals(1, fixture.committedRecorder.eventStore()
715+
.outboxAfter("java-graph-source", 0, 10).size());
716+
717+
fixture.context.putAttribute(AppAuditEventSink.class.getName(),
718+
(AppAuditEventSink) (caller, event) -> fixture.audit.add(event));
719+
order.auditAs("retry after audit delivery failure").save(fixture.context);
720+
assertEquals(1, fixture.committedRecorder.eventStore()
721+
.outboxAfter("java-graph-source", 0, 10).size());
722+
}
723+
724+
@Test public void postCommitAuditFailureStillAttemptsEveryGraphMember() throws Exception {
725+
var fixture = new Fixture(true);
726+
var attempts = new java.util.concurrent.atomic.AtomicInteger();
727+
fixture.context.putAttribute(AppAuditEventSink.class.getName(),
728+
(AppAuditEventSink) (caller, event) -> {
729+
attempts.incrementAndGet();
730+
throw new IllegalStateException("diagnostic audit sink failure");
731+
});
732+
var order = fixture.create("CustomerOrder", 198, "root audit fails");
733+
var item = fixture.create("OrderItem", 298, "child audit fails");
734+
order.__internalSet("children", List.of(item));
735+
736+
var failure = assertThrows(AuditDeliveryAfterCommitException.class,
737+
() -> order.auditAs("commit graph before audit delivery").save(fixture.context));
738+
assertEquals(2, failure.failureCount());
739+
assertEquals(2, attempts.get());
740+
assertEquals(1, fixture.committedRecorder.eventStore()
741+
.outboxAfter("java-graph-source", 0, 10).size());
742+
assertEquals(1, fixture.driver.queryForList(
743+
"SELECT id FROM customer_order_data WHERE id = ?", new Object[] {198L}).size());
744+
assertEquals(1, fixture.driver.queryForList(
745+
"SELECT id FROM order_item_data WHERE id = ?", new Object[] {298L}).size());
746+
}
747+
748+
@Test public void standardAuditSinkFailureDoesNotSuppressApplicationAuditDelivery() throws Exception {
749+
var fixture = new Fixture(true);
750+
fixture.failStandardAudit = true;
751+
var order = fixture.create("CustomerOrder", 197, "both audit sinks are attempted");
752+
753+
var failure = assertThrows(AuditDeliveryAfterCommitException.class,
754+
() -> order.auditAs("commit before standard audit delivery").save(fixture.context));
755+
assertEquals(1, failure.failureCount());
756+
assertEquals(1, fixture.audit.size());
757+
assertEquals(1, fixture.committedRecorder.eventStore()
758+
.outboxAfter("java-graph-source", 0, 10).size());
759+
}
760+
691761
@Test public void realSqliteProviderFailureKeepsAttemptedLineageWithoutCommittedAudit() throws Exception {
692762
var fixture = new Fixture();
693763
fixture.driver.execute("CREATE UNIQUE INDEX payment_name_unique ON payment_data(name)");

0 commit comments

Comments
 (0)