Skip to content

Commit cdc8a1c

Browse files
author
Philip Z
committed
[skip ci] Integrate approved post-commit audit and reconciliation fixes
2 parents 54d0f11 + f61d8bd commit cdc8a1c

7 files changed

Lines changed: 304 additions & 29 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: 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+
}
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: 73 additions & 27 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,12 +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(context, completed);
581-
entityMutationLedger.clearCurrentChangeSet();
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;
582601
telemetryScope.success();
583602
} catch (RuntimeException | Error error) {
584603
telemetryScope.failure(error);
@@ -1102,18 +1121,34 @@ private List<PendingMutation> executeLedgerPlan(
11021121
return completed;
11031122
}
11041123

1105-
private void completeLedgerPlan(UserContext context, List<PendingMutation> completed) {
1124+
private void completeLedgerPlan(List<PendingMutation> completed) {
11061125
for (PendingMutation mutation : completed) {
11071126
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());
11111127
mutation.target().clearUpdatedProperties();
1112-
// A committed delete supersedes any staged extension edits too.
1113-
mutation.target().__internalClearDynamicMutations();
11141128
}
11151129
}
11161130

1131+
private void deliverCommittedAuditEvents(UserContext context, List<PendingMutation> completed) {
1132+
int failures = 0;
1133+
for (PendingMutation mutation : completed) {
1134+
try {
1135+
failures += emitAuditEvent(
1136+
context, mutation.target(), mutation.auditKind(), mutation.changedValues(),
1137+
mutation.governance(), mutation.intent(), mutation.traceChain(), mutation.redactions(),
1138+
mutation.result().persistedEntity().getVersion());
1139+
} catch (RuntimeException failure) {
1140+
// The provider transaction has already committed. Continue delivery
1141+
// for the other graph members and never expose a retryable save error.
1142+
failures++;
1143+
} finally {
1144+
// Preserve dynamic original values until the audit projection has
1145+
// observed them; then clear staged extension edits even on failure.
1146+
mutation.target().__internalClearDynamicMutations();
1147+
}
1148+
}
1149+
if (failures != 0) throw new AuditDeliveryAfterCommitException(failures);
1150+
}
1151+
11171152
private Map<String, Object> snapshotChanges(Map<String, Object> changes) {
11181153
return Collections.unmodifiableMap(new LinkedHashMap<>(changes));
11191154
}
@@ -1411,13 +1446,13 @@ private List<MutationResult> mutateBatchWithTelemetry(
14111446
}
14121447
}
14131448

1414-
private void emitAuditEvent(
1449+
private int emitAuditEvent(
14151450
UserContext context,
14161451
Entity entity,
14171452
MutationAuditKind kind,
14181453
Map<String, Object> changedValues,
14191454
MutationGovernanceSnapshot governance, MutationIntent intent, List<TraceNode> traceChain,
1420-
SqlIntentRedactions redactions) {
1455+
SqlIntentRedactions redactions, Long committedVersion) {
14211456
List<AuditFieldChange> changes = new ArrayList<>();
14221457
if (changedValues != null) {
14231458
for (Map.Entry<String, Object> entry : changedValues.entrySet()) {
@@ -1447,7 +1482,7 @@ private void emitAuditEvent(
14471482
context.getAttribute(GeneratedSchemaBootstrap.AUDIT_ACTOR_ATTRIBUTE, String.class),
14481483
context.getAttribute(GeneratedSchemaBootstrap.AUDIT_CATEGORY_ATTRIBUTE, String.class),
14491484
intent.auditReason(),
1450-
entity.getVersion(),
1485+
committedVersion,
14511486
java.time.Instant.now(),
14521487
governance);
14531488

@@ -1457,18 +1492,29 @@ private void emitAuditEvent(
14571492
"teaql.mutation.kind", kind.name().toLowerCase(Locale.ROOT),
14581493
"teaql.audit.changed_field_count", changes.size())));
14591494
try {
1495+
int failures = 0;
1496+
// The server-owned and application sinks are independent delivery
1497+
// attempts. One failure must not suppress the other after commit.
1498+
if (logSink != null) {
1499+
try {
1500+
logSink.writeAuditEvent(context,
1501+
LogPrivacy.audit(rawEvent, LogPrivacy.plaintextEnabled(), redactions));
1502+
} catch (RuntimeException failure) {
1503+
failures++;
1504+
}
1505+
}
14601506

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();
1507+
AppAuditEventSink appSink = context.capability(AppAuditEventSink.class);
1508+
if (appSink != null) {
1509+
try {
1510+
appSink.onAuditEvent(context, buildSafeAuditEvent(rawEvent, redactions));
1511+
} catch (RuntimeException failure) {
1512+
failures++;
1513+
}
1514+
}
1515+
if (failures == 0) telemetryScope.success();
1516+
else telemetryScope.failure(new AuditDeliveryAfterCommitException(failures));
1517+
return failures;
14721518
} catch (RuntimeException | Error error) {
14731519
telemetryScope.failure(error);
14741520
throw error;

0 commit comments

Comments
 (0)