Skip to content

Latest commit

 

History

History
330 lines (282 loc) · 20.9 KB

File metadata and controls

330 lines (282 loc) · 20.9 KB

Transactions

firefly/data's #[Transactional] is LaraFly's declarative transaction-demarcation model (a Spring @Transactional analog) over Laravel's own connection: a proxy generated at scan time wraps every annotated method in a TransactionInterceptor that drives transaction boundaries through manual DB::beginTransaction()/commit()/rollBack() — never DB::transaction($closure) — because only manual control lets a caught exception be committed-and-rethrown (when it matches noRollbackFor, or matches neither list) instead of unconditionally rolled back.

The #[Transactional] attribute

#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
final class Transactional
{
    public function __construct(
        public Propagation $propagation = Propagation::REQUIRED,
        public Isolation $isolation = Isolation::DEFAULT,
        public bool $readOnly = false,
        public array $rollbackFor = [Throwable::class],
        public array $noRollbackFor = [],
        public ?string $connection = null,
        public ?int $timeout = null,
    ) {}
}

On a class, it sets the default for every public method. On a method, it replaces — does not merge with — the class-level attribute for that one method (Spring semantics): a method-level #[Transactional] is the complete, effective configuration for that method, not an override of individual fields.

#[Transactional(readOnly: true)]        // class default: every public method is read-only REQUIRED
class TransferService
{
    #[Transactional(propagation: Propagation::REQUIRES_NEW)]   // replaces the class default entirely for transfer()
    public function transfer(int $amount): int { /* ... */ }

    public function balance(): int { /* ... */ }   // inherits the class-level readOnly default
}

Default rollbackFor = [Throwable::class]: PHP has no checked/unchecked exception split, so by default any throwable rolls back the transaction, unless it also matches noRollbackFor (which always wins).

timeout is in seconds and is enforced — see Timeouts.

The seven propagation modes

Propagation is an unbacked enum with all seven Spring modes, including NESTED (which Laravel's automatic savepoints make possible over a plain relational connection):

Mode Behaviour
REQUIRED (default) Joins the caller's transaction if one is active; otherwise starts a new outermost one.
REQUIRES_NEW Always starts a transaction. If none is active it becomes the new outermost transaction; if one is already active on the same connection, Laravel has no suspend primitive, so it degrades to a nested beginTransaction() — i.e. a savepoint, not a truly independent transaction (see Known-latent).
NESTED Same underlying mechanics as REQUIRES_NEW in this implementation: a fresh outermost transaction if none is active, otherwise a nested beginTransaction() that Laravel turns into a savepoint — so a NESTED failure unwinds only to its own savepoint, not the whole unit of work.
SUPPORTS Runs in the caller's transaction if one is active; otherwise runs with no transaction at all. Never starts one.
NOT_SUPPORTED Always runs with no transaction. On the same connection there is no suspend primitive, so an already-active transaction is simply not paused — the work still runs inside it (see Known-latent).
MANDATORY Requires an active transaction; runs in it if present, otherwise throws TransactionRequiredException.
NEVER Forbids an active transaction; throws TransactionNotAllowedException if one is active, otherwise runs with none.

TransactionTemplate::execute() is the single source of truth both the generated proxy and any programmatic caller go through — there is no second code path to keep in sync.

Isolation, read-only, and rollback rules

Isolation is a string-backed enum whose value is the SQL clause:

enum Isolation: string
{
    case DEFAULT = 'DEFAULT';                  // no SET at all — leaves the connection's own default
    case READ_UNCOMMITTED = 'READ UNCOMMITTED';
    case READ_COMMITTED = 'READ COMMITTED';
    case REPEATABLE_READ = 'REPEATABLE READ';
    case SERIALIZABLE = 'SERIALIZABLE';
}

On the outermost transaction of a unit of work, a non-DEFAULT isolation issues SET TRANSACTION ISOLATION LEVEL {value}; readOnly: true issues SET TRANSACTION READ ONLY. Both are best-effort: either statement failing (a driver that doesn't support it) is caught and silently ignored rather than failing the whole transaction — see Known-latent.

rollbackFor/noRollbackFor are evaluated in that order when the wrapped work throws:

  1. If the thrown exception is an instance of anything in noRollbackFor, the transaction commits and the exception is rethrown (noRollbackFor always wins, even over a matching rollbackFor).
  2. Otherwise, if it matches rollbackFor (default [Throwable::class], i.e. everything), the transaction rolls back and the exception is rethrown.
  3. Otherwise (matches neither list — only reachable with a narrowed rollbackFor), the transaction commits and the exception is rethrown.

If that commit-and-rethrow's commit itself fails (a deferred constraint reported at COMMIT, a connection lost on the way), the transaction is rolled back instead and a TransactionSystemException (TRANSACTION_SYSTEM_ERROR, 500) escapes carrying both failures — Spring's shape: the commit failure is its previous, the method's own exception its $applicationException. A commit that fails after the method returned is rolled back the same way and the failure is rethrown on its own.

#[Transactional(noRollbackFor: [IgnorableException::class])]
public function logButKeep(): void
{
    DB::table('accounts')->insert(['name' => 'kept']);

    throw new IgnorableException('ignored');   // insert survives: commit-and-rethrow
}

Either way — commit or roll back — after-commit domain events queued during the unit of work are drained via DomainEventDispatcher::dispatchAfterCommit() before the transaction is resolved, on the descriptor's own connection, so a #[Transactional(connection: 'x')] method fires its listeners on x's commit; Laravel discards afterCommit callbacks on rollback, so a listener never sees an event from a rolled-back unit of work. See Domain (DDD).

Timeouts

#[Transactional(timeout: 5)] (seconds) is enforced on the outermost transaction the template starts. Right after beginTransaction() the driver is told to give up on a statement past the budget — pgsql SET LOCAL statement_timeout (transaction-scoped, nothing to restore), mysql SET SESSION max_execution_time (milliseconds, SELECTs only) or mariadb SET SESSION max_statement_time (seconds, any statement — mariadb has no max_execution_time variable, and a mysql connection whose server is mariadb is detected through the server version), each with innodb_lock_wait_timeout, every previous value read first and restored in a finally independently of the other, sqlite PDO::ATTR_TIMEOUT (the busy timeout — the only knob sqlite has) restored to the configured busy_timeout — and a wall-clock deadline is taken. When the method returns past that deadline the transaction is rolled back and TransactionTimedOutException (504 TRANSACTION_TIMED_OUT) is thrown; a method whose own exception ended it keeps that exception. A joined REQUIRED and a NESTED savepoint run under the outer budget — Spring semantics. firefly.data.transaction.default-timeout applies when the attribute names none (0 = no deadline); firefly.data.transaction.statement-timeout=false keeps only the wall-clock check.

Transactional event listeners

#[TransactionalEventListener] is the transaction-aware alternative to #[AsEventListener] — Spring's @TransactionalEventListener:

#[Component]
final class OrderAudit
{
    #[TransactionalEventListener]                                              // AFTER_COMMIT
    public function record(OrderPlaced $event): void { /* the row is committed */ }

    #[TransactionalEventListener(phase: TransactionPhase::BEFORE_COMMIT)]
    public function check(OrderPlaced $event): void { /* inside the transaction; a throw aborts the commit */ }

    #[TransactionalEventListener(phase: TransactionPhase::AFTER_ROLLBACK, fallbackExecution: true)]
    public function undo(OrderPlaced $event): void { /* also runs at once when no transaction is active */ }
}

The event is published immediately (a plain #[AsEventListener] on the same event still runs inside the transaction); this listener is queued on the current transaction and runs in its phase — BEFORE_COMMIT inside the commit (Laravel's TransactionCommitting, before the PDO commit, so a throw rolls back), AFTER_COMMIT after the root commit (Connection::afterCommit(), discarded on rollback), AFTER_ROLLBACK after a rollback (Connection::afterRollBack()), AFTER_COMPLETION after either. A listener queued inside a savepoint (NESTED, or REQUIRES_NEW joined on the same connection) belongs to that savepoint: when it rolls back and the outer transaction goes on to commit, the event's listeners see AFTER_ROLLBACK (and AFTER_COMPLETION) and nothing else. With no active transaction the listener is skipped unless fallbackExecution: true. The event class is inferred from the first parameter (or named with event:); order sorts transactional listeners among themselves. They are compiled by the same scanner into the manifest's listeners map and registered by DataWiringProvider's TransactionalEventListenerWiringPass at BootPhase::EventListeners; TransactionSynchronizationRegistry is the bean that queues them, and TransactionTemplate tells it which connection is current so a #[Transactional(connection: 'x')] method's listeners bind to x (an event published inside a plain DB::transaction() binds to the default connection).

Relationship with the domain-event bridge: DomainEventDispatcher::publishAfterCommit() defers the event — nobody hears it before commit; #[TransactionalEventListener] defers the listener — the event is heard now, this method later. Use the bridge for aggregates' domain events, the attribute for a listener that must see committed state (or a rollback) for any application event.

Exception translation

TransactionTemplate::execute() translates whatever escapes any propagation arm into the kernel's DataAccessException family (see Data & Repositories), so a #[Transactional] method throws DuplicateKeyException, BadSqlGrammarException, … whether or not the failing statement went through a repository. rollbackFor/noRollbackFor are matched against the translated exception and the original underneath it, so a noRollbackFor: [QueryException::class] written before translation existed still matches. A commit that itself fails is rolled back before the failure is rethrown.

The proxy model

A #[Transactional] bean is not called directly — TransactionalBeanPostProcessor (a #[Component] discovered by its BeanPostProcessor interface and installed by RegisterBeanPostProcessorsPass at phase 700) swaps it, on the second BPP pass (after #[PostConstruct] has already run on the real bean), for an instance of a generated final class {Target}__FireflyTransactionalProxy extends {Target}. Since the interceptor chain landed, the same proxy carries every advice a class runs, not only transactions: each override builds a MethodInvocation over the ORDERED interceptors compiled for that method and the descriptors baked for it, and proceed() walks the chain outermost-first before reaching parent:::

public function transfer(int $amount): int
{
    return (new \Firefly\Data\Proxy\MethodInvocation(
        $this,
        \App\AccountService::class,
        'transfer',
        [$amount],
        [$this->__fireflySecurityInterceptor, $this->__fireflyTxInterceptor],
        [
            \Firefly\Security\Access\Method\SecurityMethodDescriptor::class => self::__fireflySecurityDescriptor('transfer'),
            \Firefly\Data\Transaction\TransactionalDescriptor::class => self::__fireflyTxDescriptor('transfer'),
        ],
        fn (array $__fireflyArgs) => parent::transfer(...$__fireflyArgs),
    ))->proceed();
}

The pieces, all in Firefly\Data\Proxy:

  • MethodInterceptor — the AOP Alliance port: invoke(MethodInvocation): mixed. TransactionInterceptor implements it (its invoke() reads the TransactionalDescriptor off the invocation and calls the unchanged run(), which delegates to TransactionTemplate::execute()); firefly/security's MethodSecurityInterceptor is the other shipped link.
  • MethodInvocation — single-use: proceed(), getArguments()/setArguments() (a #[PreFilter] narrows what the method receives), descriptor(class) (each link reads its own baked descriptor), getThis(), getDeclaredClass(), getMethod().
  • Advice — a KIND of advice: id (tx, security), interceptor bean class, descriptor class, and order — lower runs outer. The transactional advice is 1000 and security's is 100, so a refusal never opens a transaction. An advice whose interceptor is switched off by design (security's, under the master flag) says so with inertWhenUnbound; every other advice whose interceptor has vanished fails loud at wrap time.
  • AdviceSource — the port a package implements to contribute: scan() (its own sanctioned scanner, at cache time), render() (the PHP literal baked into the proxy) and advice(). TransactionalAdviceSource is Data's; MethodSecurityAdviceSource is Security's. Both are #[Component]s, so the uncached boot collects them through Container::getAll().
  • ProxyPlan — the merged manifest (proxy-plan.php, written by firefly:cache): per class, the proxy class, the advice kinds and, per method, the ordered (advice, descriptor row) pairs. ProxyPlanner builds it from the sources and renders the generator's inputs; InterceptorRegistry resolves each advice's interceptor bean at wrap time, degrading to a PassThroughInterceptor when that capability is switched off.

The plan is resolved like every manifest: the compiled proxy-plan.php; else — a cache from before that file existed, holding transactional.php and its proxies but no plan — a transactional-only plan bridged from the TransactionalManifest that loaded it (a cached app trusts its artifacts and never falls back to the scan; an advice such a plan knows nothing about is a reason to recompile, which is why firefly/security refuses to boot over its compiled rules beside a plan-less cache); else an in-process scan of firefly.scan.paths through every AdviceSource; else — no scan paths but a bound TransactionalManifest — a transactional-only plan derived from it. ProxyFactory instantiates the proxy state-preservingly: newInstanceWithoutConstructor() (so #[PostConstruct] is not re-run), then the real bean's initialised state is copied slot by slot, each slot written by a closure bound to the class that declares it — never ReflectionProperty::setValue() — and a last bound closure sets one private interceptor property per advice the generated __fireflyAdvice() table names. Writing from the declaring class is what lets the copy reach a private on a parent (EloquentRepository's translator under every #[Repository]) and initialise a parent's protected readonly (EloquentRepository's manifest and tracker) on PHP 8.3, where a readonly property is initialisable from its declaring class's scope alone; two privates under one name in a parent and a child stay two slots. The proxy is-a {Target}, so container calls and #[PreDestroy] resolve against it exactly as they would the original bean. A final target is refused at scan time (UnsupportedTransactionalMethodException::finalClass()), where the manifest row is still in view.

Self-invocation bypasses the proxy — the same well-known Spring limitation. A method calling $this->otherMethod() from inside the proxied class calls straight through parent::, skipping the interceptor entirely. To get transactional semantics for an inner unit of work from within another method, call through the injected TransactionTemplate instead:

#[Service]
#[Transactional]
class AccountService
{
    public function __construct(private readonly TransactionTemplate $template) {}

    public function outerWithNested(): void
    {
        DB::table('accounts')->insert(['name' => 'outer']);

        try {
            $this->template->execute(function (): void {
                DB::table('accounts')->insert(['name' => 'inner']);
                throw new RuntimeException('inner fail');
            }, new TransactionalDescriptor(propagation: Propagation::NESTED));
        } catch (RuntimeException) {
            // outer commit is unaffected — only the NESTED savepoint unwound
        }
    }
}

TransactionTemplate::execute() is the programmatic twin of #[Transactional] for exactly this case (or for any transactional unit of work that isn't a whole bean method):

$template->execute(function (): void {
    DB::table('widgets')->insert(['name' => 'a']);
    DB::table('widgets')->insert(['name' => 'b']);
});                                        // no descriptor -> REQUIRED / default isolation / rollback-on-Throwable

$template->execute($work, new TransactionalDescriptor(
    propagation: Propagation::REQUIRES_NEW,
    noRollbackFor: [IgnorableException::class],
));

Known-latent

  • The manifest and its proxies must stay one matched unit — and they now are, on both boot paths. DataAutoConfiguration::transactionalManifest() resolves the compiled transactional.php if firefly:cache wrote one (registering the proxies.php classmap autoloader first, so firefly/cli is not required at runtime), otherwise the ProxyPlan bean scans firefly.scan.paths through every AdviceSource and materialises each planned proxy per process through ProxyMaterializer — a private 0700 directory written with O_EXCL, dev-time cost only. Proxies are made loadable before the manifest is handed out, because TransactionalBeanPostProcessor throws a ConfigurationException when the manifest promises a proxy class it cannot find; a half-emitted cache therefore fails at boot rather than quietly running unproxied. Until this landed, the auto-config bound an unconditional empty manifest and nothing loaded the compiled transactional.php, so #[Transactional] was a silent no-op in any application that did not hand-write its own manifest configuration — which is precisely what the skeleton's app/Support/CachedTransactionalConfiguration.php existed to do, and why it has been deleted.
  • REQUIRES_NEW/NOT_SUPPORTED cannot truly suspend an active transaction on the same connection — Laravel has no suspend primitive. REQUIRES_NEW is genuinely independent only when it targets a distinct configured connection from the caller's; on the same connection it degrades to a nested savepoint instead. NOT_SUPPORTED on the same connection cannot pause the ambient transaction either — the work still runs inside it rather than truly outside a transaction.
  • Isolation and read-only are driver-dependent. The SET TRANSACTION ISOLATION LEVEL/SET TRANSACTION READ ONLY statements are issued best-effort and swallowed on failure — SQLite, for instance, ignores or limits both. The driver-level statement timeout is best-effort in the same way: sqlite has only a busy timeout (a merely slow statement is not interruptible, and the wall-clock check catches it on return), and mysql's max_execution_time applies to SELECTs only (mariadb's max_statement_time interrupts any statement).
  • #[TransactionalEventListener] needs the connection's event dispatcher for BEFORE_COMMIT (every Laravel-configured connection has one; a hand-built Connection without setEventDispatcher() gets a ConfigurationException at the first BEFORE_COMMIT registration). order sorts transactional listeners among themselves, not against #[AsEventListener]s of the same event, which always run first. The scanner reads concrete classes in firefly.scan.paths; a listener on a class produced only by a #[Bean] factory is not discovered.
  • Auditing's created_by/updated_by no-op until the M11 security-context principal is bound — see Relational Data for the full behaviour and how it turns on.
  • Auto after-commit dispatch covers aggregates saved through a Firefly repository (EloquentRepository:: save() registering with AggregateTracker) within the transaction. For a recorder not saved that way, use the explicit DomainEventDispatcher::publishAfterCommit($aggregate, $connection) escape hatch to get the same after-commit-only publish guarantee.