@ -75,6 +75,7 @@ import {
ORCA_VALIDATE_ORPHAN_UNLESS ,
ORCA_VALIDATE_SEVERITY_ERROR ,
ORCA_VALIDATE_SEVERITY_WARN ,
ORCA_VALIDATE_TRANSACTION_WITHOUT_COMPENSATE ,
ORCA_VALIDATE_UNSATISFIABLE_AFTER
} from './consts.ts' ;
import {
@ -408,8 +409,16 @@ export function createEngineOrca(options: EngineOrcaOptions): EngineOrca {
const tokens = new Set < OrcaToken > ( ) ;
const actionRuns : OrcaActionRun [ ] = [ ] ;
// Stack of actions that completed success AND declared a compensate.
// Pushed in completion order so reverse iteration is LIFO.
const compensable : Array < { action : OrcaAction ; payload : unknown } > = [ ] ;
// Pushed in completion order so reverse iteration is LIFO. The
// `compensated` flag prevents an action from being compensated
// twice when its transaction failed mid-run and the global pre-
// FINALLY rollback later iterates the same list.
const compensable : Array < {
action : OrcaAction ;
payload : unknown ;
transactionId? : string ;
compensated : boolean ;
} > = [ ] ;
const compensations : OrcaActionRun [ ] = [ ] ;
let compensationsRun = false ;
const controller = new AbortController ( ) ;
@ -441,14 +450,17 @@ export function createEngineOrca(options: EngineOrcaOptions): EngineOrca {
) {
compensationsRun = true ;
for ( let i = compensable . length - 1 ; i >= 0 ; i -- ) {
const entry = compensable [ i ] ;
if ( entry . compensated ) continue ;
const compResult = await runCompensation (
compensable [ i ] . action ,
compensable[ i ] . payload ,
entry . action ,
entry . payload ,
envelope ,
runId ,
tokens
) ;
compensations . push ( compResult ) ;
entry . compensated = true ;
}
}
@ -484,13 +496,66 @@ export function createEngineOrca(options: EngineOrcaOptions): EngineOrca {
item . action . compensate !== undefined &&
stage !== ORCA_STAGE_FINALLY
) {
compensable . push ( { action : item.action , payload : envelope.payload } ) ;
compensable . push ( {
action : item.action ,
payload : envelope.payload ,
transactionId : item.action.transaction ,
compensated : false
} ) ;
}
for ( const token of item . actionRun . emitted ) tokens . add ( token ) ;
}
// Decide whether the run should abort. Fatal beats error;
// within a wave, registration order resolves ties.
// Detect transaction failure first: any tx member that
// errored or went fatal triggers an immediate compensation
// of its tx peers (LIFO over compensable entries belonging
// to that tx) and aborts the run. Tx semantics override
// the member's own `onError`.
const failedTxIds = new Set < string > ( ) ;
let txCause : unknown | undefined ;
for ( const item of waveItems ) {
const txId = item . action . transaction ;
if ( ! txId ) continue ;
if (
item . actionRun . status === ORCA_ACTION_STATUS_ERROR ||
item . actionRun . status === ORCA_ACTION_STATUS_FATAL
) {
if ( txCause === undefined ) txCause = item . actionRun . error ;
failedTxIds . add ( txId ) ;
}
}
if ( failedTxIds . size > 0 ) {
for ( const txId of failedTxIds ) {
for ( let i = compensable . length - 1 ; i >= 0 ; i -- ) {
const entry = compensable [ i ] ;
if ( entry . compensated ) continue ;
if ( entry . transactionId !== txId ) continue ;
const compResult = await runCompensation (
entry . action ,
entry . payload ,
envelope ,
runId ,
tokens
) ;
compensations . push ( compResult ) ;
entry . compensated = true ;
}
}
aborted = true ;
controller . abort ( ) ;
emitOrcaDiagnostic ( diagnostics , ORCA_DIAGNOSTIC_EVENTS . RUN_ABORTED , {
runId ,
event : envelope.event ,
eventId : envelope.meta.eventId ,
traceId : envelope.meta.traceId ,
depth : envelope.meta.depth ,
cause : txCause
} ) ;
break ;
}
// Standard abort path. Fatal beats error; within a wave,
// registration order resolves ties.
let abortCause : unknown | undefined ;
let abortTriggered = false ;
for ( const item of waveItems ) {
@ -754,7 +819,8 @@ export function createEngineOrca(options: EngineOrcaOptions): EngineOrca {
startedAt ,
endedAt ,
durationMs : endedAt - startedAt ,
emitted : [ ]
emitted : [ ] ,
transactionId : action.transaction
} ;
} catch ( thrown ) {
const endedAt = timers . clock . now ( ) ;
@ -775,7 +841,8 @@ export function createEngineOrca(options: EngineOrcaOptions): EngineOrca {
endedAt ,
durationMs : endedAt - startedAt ,
emitted : [ ] ,
error : thrown
error : thrown ,
transactionId : action.transaction
} ;
}
}
@ -857,7 +924,8 @@ export function createEngineOrca(options: EngineOrcaOptions): EngineOrca {
endedAt : startedAt ,
durationMs : 0 ,
emitted : [ ] ,
reason : ORCA_GATE_REASON_RUN_ABORTED
reason : ORCA_GATE_REASON_RUN_ABORTED ,
transactionId : action.transaction
} ;
}
@ -946,7 +1014,8 @@ export function createEngineOrca(options: EngineOrcaOptions): EngineOrca {
status === ORCA_ACTION_STATUS_INTERRUPTED ||
status === ORCA_ACTION_STATUS_SKIPPED
? ( result as { reason? : string } ) . reason
: undefined
: undefined ,
transactionId : action.transaction
} ;
} catch ( thrown ) {
// Uncaught exceptions become OrcaError, regardless of intent.
@ -968,7 +1037,8 @@ export function createEngineOrca(options: EngineOrcaOptions): EngineOrca {
endedAt ,
durationMs : endedAt - startedAt ,
emitted : [ ] ,
error : thrown
error : thrown ,
transactionId : action.transaction
} ;
}
}
@ -1134,7 +1204,8 @@ function interruptedActionRun(
endedAt : now ,
durationMs : 0 ,
emitted : [ ] ,
reason
reason ,
transactionId : action.transaction
} ;
}
@ -1165,7 +1236,8 @@ function evaluateGates(
endedAt : now ,
durationMs : 0 ,
emitted : [ ] ,
reason : ` ${ ORCA_GATE_REASON_UNLESS_TRIGGERED } : ${ matched } `
reason : ` ${ ORCA_GATE_REASON_UNLESS_TRIGGERED } : ${ matched } ` ,
transactionId : action.transaction
} ;
}
}
@ -1180,7 +1252,8 @@ function evaluateGates(
endedAt : now ,
durationMs : 0 ,
emitted : [ ] ,
reason : ` ${ ORCA_GATE_REASON_ABORT_ON_TRIGGERED } : ${ matched } `
reason : ` ${ ORCA_GATE_REASON_ABORT_ON_TRIGGERED } : ${ matched } ` ,
transactionId : action.transaction
} ;
}
}
@ -1195,7 +1268,8 @@ function evaluateGates(
endedAt : now ,
durationMs : 0 ,
emitted : [ ] ,
reason : ` ${ ORCA_GATE_REASON_AFTER_NOT_MET } : ${ missing . join ( ',' ) } `
reason : ` ${ ORCA_GATE_REASON_AFTER_NOT_MET } : ${ missing . join ( ',' ) } ` ,
transactionId : action.transaction
} ;
}
}
@ -1251,6 +1325,7 @@ function validateConfiguration(
const sorted = sortActionsCanonical ( registered ) ;
validateGatesForEvent ( event , sorted , issues ) ;
validateCyclesForEvent ( event , sorted , issues ) ;
validateTransactionsForEvent ( event , sorted , issues ) ;
}
const ok = issues . every ( ( i ) = > i . severity !== ORCA_VALIDATE_SEVERITY_ERROR ) ;
return { ok , issues } ;
@ -1425,6 +1500,37 @@ function validateCyclesForEvent(
for ( const id of adjacency . keys ( ) ) dfs ( id ) ;
}
/ * *
* Group actions by their ` transaction ` tag and report any group whose
* members all lack a ` compensate ` function — the rollback would have
* nothing to undo , so the transaction is effectively decorative .
* Reported once per offending tx id , not per member .
* /
function validateTransactionsForEvent (
event : string ,
sorted : readonly RegisteredActionEntry [ ] ,
issues : OrcaValidateIssue [ ]
) : void {
const byTx = new Map < string , OrcaAction [ ] > ( ) ;
for ( const { action } of sorted ) {
if ( ! action . transaction ) continue ;
const list = byTx . get ( action . transaction ) ? ? [ ] ;
list . push ( action ) ;
byTx . set ( action . transaction , list ) ;
}
for ( const [ txId , members ] of byTx ) {
const anyCompensable = members . some ( ( m ) = > m . compensate !== undefined ) ;
if ( anyCompensable ) continue ;
issues . push ( {
kind : ORCA_VALIDATE_TRANSACTION_WITHOUT_COMPENSATE ,
severity : ORCA_VALIDATE_SEVERITY_WARN ,
event ,
transactionId : txId ,
message : ` transaction " ${ txId } " on event " ${ event } " has no member with \` compensate \` — rollback would be a no-op `
} ) ;
}
}
/** Rotate the cycle so the smallest id comes first; used to dedupe. */
function canonicalCycleFingerprint ( cycle : readonly OrcaActionId [ ] ) : string {
if ( cycle . length === 0 ) return '' ;