aeatech/transaction-manager-core

Transaction manager core

Installs: 37

Dependents: 5

Suggesters: 0

Security: 0

Stars: 0

Watchers: 0

Forks: 0

Open Issues: 0

pkg:composer/aeatech/transaction-manager-core

1.0.0 2025-12-24 22:56 UTC

This package is not auto-updated.

Last update: 2026-01-12 10:25:51 UTC


README

Code Coverage

A lightweight, DB-agnostic transaction manager designed for high-load environments, with first-class support for:

  • retries
  • exponential backoff
  • transient/connection error classification
  • commit-unknown detection
  • strict idempotency contracts
  • pure SQL transaction execution
  • deferred build for dependent transactions

The core package contains no DB-specific logic.
MySQL and PostgreSQL integrations live in separate packages:

  • transaction-manager-doctrine-adapter (bridge for Doctrine DBAL)
  • transaction-manager-mysql
  • transaction-manager-postgresql

Features

  • Execute a sequence of SQL operations as a single logical transaction
  • Automatically retry on transient errors (deadlocks, serialization failures)
  • Automatically recovers from connection drops
  • Handle unknown commit outcome
  • IsolationLevel level control
  • Pure, deterministic execution plan
  • Deferred Build: support for dependent transactions (e.g., use generated IDs from previous steps)
  • Backoff strategies (exponential with jitter)
  • Pluggable error classifiers per-database
  • Optional prepared statement reuse via StatementReusePolicy (disabled by default)
  • No global state, no magic

If you need the full detailed contract describing all guarantees, responsibilities, idempotency rules, error semantics, and connection requirements, see:

👉 CONTRACT.md

Installation

composer require aeatech/transaction-manager-core

Quick Example

use AEATech\TransactionManager\TransactionManager;
use AEATech\TransactionManager\TxOptions;
use AEATech\TransactionManager\RetryPolicy;
use AEATech\TransactionManager\ExponentialBackoff;
use AEATech\TransactionManager\SystemSleeper;
use AEATech\TransactionManager\ExecutionPlanBuilder;
use AEATech\TransactionManager\TransactionInterface;
use AEATech\TransactionManager\Query;
use AEATech\TransactionManager\GenericErrorClassifier;

// Assume $connectionAdapter implements AEATech\TransactionManager\ConnectionInterface
$connectionAdapter = '...';

// Assume $errorClassifier implements AEATech\TransactionManager\ErrorClassifierInterface
$errorClassifier = new GenericErrorClassifier($errorHeuristics);

$tm = new TransactionManager(
    new ExecutionPlanBuilder(),
    $connectionAdapter,
    $errorClassifier,
    RetryPolicy::noRetry(), // Default retry policy
    new SystemSleeper()
);

class InsertUser implements TransactionInterface
{
    public function __construct(
        private string $email,
        private string $name
    ) {}

    public function build(): Query
    {
        return new Query(
            sql: "INSERT INTO users (email, name) VALUES (?, ?)",
            params: [$this->email, $this->name],
            types: [\PDO::PARAM_STR, \PDO::PARAM_STR]
        );
    }

    public function isIdempotent(): bool
    {
        return false; // inserting the same user twice is not safe
    }
}

$result = $tm->run(new InsertUser('a@example.com', 'Alice'));

echo $result->affectedRows; // → 1

Idempotent Transaction Example

class SetStock implements TransactionInterface
{
    public function __construct(
        private int $productId,
        private int $qty
    ) {}

    public function build(): Query
    {
        return new Query(
            sql: "UPDATE products SET stock = ? WHERE id = ?",
            params: [$this->qty, $this->productId],
            types: [\PDO::PARAM_INT, \PDO::PARAM_INT]
        );
    }

    public function isIdempotent(): bool
    {
        return true; // Setting a value twice results in the same state
    }
}

Executing Multiple Transactions as One

$tm->run([
    new SetStock(10, 5),
    new SetStock(11, 0),
    new InsertUser("a@example.com", "Alice"),
]);

Deferred Build (Dependent Transactions)

By default, TransactionInterface::build() is called before the database transaction starts. This ensures that the execution plan is deterministic and minimizes the database transaction's duration.

However, sometimes a transaction depends on data generated by a previous step in the same batch (e.g., using an AUTO_INCREMENT ID from a previous INSERT). In such cases, you can use the #[DeferredBuild] attribute.

How it works:

  1. When a transaction class is marked with #[DeferredBuild], its build() method is not called during the initial plan construction.
  2. Instead, TransactionManager calls build() inside the active database transaction, right before executing the query.
  3. This allows you to perform SELECT queries (e.g., via a repository) to fetch IDs generated by previous steps and use them to build the current query.

Example:

use AEATech\TransactionManager\Attribute\DeferredBuild;
use AEATech\TransactionManager\TransactionInterface;
use AEATech\TransactionManager\Query;

#[DeferredBuild]
class CreateProfile implements TransactionInterface
{
    public function __construct(
        private UserRepository $repository,
        private string $email,
        private array $profileData
    ) {}

    public function build(): Query
    {
        // This code runs INSIDE the DB transaction.
        // We can fetch the user ID that was inserted in the previous step.
        $user = $this->repository->findByEmail($this->email);
        
        return new Query(
            sql: "INSERT INTO profiles (user_id, bio) VALUES (?, ?)",
            params: [$user->id, $this->profileData['bio']]
        );
    }

    public function isIdempotent(): bool { return false; }
}

// Usage in a batch:
$tm->run([
    new InsertUser("a@example.com", "Alice"),
    new CreateProfile($userRepository, "a@example.com", ['bio' => 'Software Engineer'])
]);

Retry Options

Transaction Manager uses a default retry policy configured during instantiation. You can override this policy for specific transactions using TxOptions.

1. Default Retry Policy

When you create the TransactionManager, you must provide a default policy.

Note on maxRetries: The value specifies the number of additional attempts after the initial failure. For example, maxRetries: 3 means the transaction can be executed up to 4 times in total (1 initial attempt + 3 retries).

$defaultPolicy = new RetryPolicy(
    maxRetries: 3, // Total 4 attempts
    backoffStrategy: new ExponentialBackoff(baseDelayMs: 100)
);

$tm = new TransactionManager(
    $builder,
    $connection,
    $classifier,
    $defaultPolicy,
    $sleeper
);

2. Disabling Retries

If you want to ensure a transaction is executed exactly once without any retries, use RetryPolicy::noRetry():

// Globally (in constructor)
$tm = new TransactionManager(..., RetryPolicy::noRetry(), ...);

// Or for a specific call
$tm->run($tx, new TxOptions(retryPolicy: RetryPolicy::noRetry()));

3. Overriding Policy per Call

use AEATech\TransactionManager\RetryPolicy;
use AEATech\TransactionManager\TxOptions;
use AEATech\TransactionManager\IsolationLevel;
use AEATech\TransactionManager\ExponentialBackoff;

$customPolicy = new RetryPolicy(
    maxRetries: 5, // Total 6 attempts
    backoffStrategy: new ExponentialBackoff(baseDelayMs: 50)
);

$options = new TxOptions(
    isolationLevel: IsolationLevel::RepeatableRead,
    retryPolicy: $customPolicy // Overrides the default policy
);

$tm->run($transaction, $options);

Isolation level (optional)

TxOptions::$isolationLevel is optional.

  • If isolationLevel is null, Transaction Manager does not issue SET TRANSACTION ISOLATION LEVEL ... and the effective isolation level is whatever is currently configured for the connection/database (server default, pool/session settings, etc.).
  • If isolationLevel is set (e.g. ReadCommitted, RepeatableRead, Serializable), Transaction Manager will explicitly set it for the current transaction.

Retry with explicit isolation level

$options = new TxOptions(
    isolationLevel: IsolationLevel::RepeatableRead,
    retryPolicy: $customPolicy
);

$tm->run($transaction, $options);

Retry without changing isolation level (use DB/connection default)

$options = new TxOptions(
    isolationLevel: null,
    retryPolicy: $customPolicy
);

$tm->run($transaction, $options);

Prepared Statement Reuse Hint

StatementReusePolicy is an optional, best‑effort hint that may help a connection adapter optimize execution of similar queries. It does not affect correctness and may be ignored by an implementation.

Options:

  • StatementReusePolicy::None — no reuse (default).
  • StatementReusePolicy::PerTransaction — attempt to reuse a prepared statement within a single transaction.
  • StatementReusePolicy::PerConnection — attempt to reuse a prepared statement across transactions while the physical connection remains open.

Example usage in TransactionInterface::build():

use AEATech\TransactionManager\Query;
use AEATech\TransactionManager\StatementReusePolicy;

class InsertUser implements TransactionInterface
{
    public function __construct(
        private string $email,
        private string $name
    ) {}

    public function build(): Query
    {
        return new Query(
            sql: "INSERT INTO users (email, name) VALUES (?, ?)",
            params: [$this->email, $this->name],
            types: [\PDO::PARAM_STR, \PDO::PARAM_STR],
            statementReusePolicy: StatementReusePolicy::PerTransaction
        );
    }

    public function isIdempotent(): bool { return false; }
}

Testing

The project is configured to run tests in Isolated Docker containers for different PHP versions (8.2, 8.3, 8.4).

1. Start the Environment

Make sure the Docker containers are up and running. From the project root:

docker-compose -p aeatech-transaction-manager-core -f docker/docker-compose.yml up -d --build

2. Install Dependencies

Install composer dependencies inside the container (using PHP 8.2 as a base):

docker-compose -p aeatech-transaction-manager-core -f docker/docker-compose.yml exec php-cli-8.2 composer install

3. Run Tests

PHP 8.2

docker-compose -p aeatech-transaction-manager-core -f docker/docker-compose.yml exec php-cli-8.2 vendor/bin/phpunit

PHP 8.3

docker-compose -p aeatech-transaction-manager-core -f docker/docker-compose.yml exec php-cli-8.3 vendor/bin/phpunit

PHP 8.4

docker-compose -p aeatech-transaction-manager-core -f docker/docker-compose.yml exec php-cli-8.4 vendor/bin/phpunit

Run All Tests (Bash Script)

for v in 8.2 8.3 8.4; do \
    echo "Testing PHP $v..."; \
    docker-compose -p aeatech-transaction-manager-core -f docker/docker-compose.yml exec php-cli-$v vendor/bin/phpunit || break; \
done

4. Run phpstan

docker-compose -p aeatech-transaction-manager-core -f docker/docker-compose.yml exec php-cli-8.4 vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=1G

Stopping the Environment

docker-compose -p aeatech-transaction-manager-core -f docker/docker-compose.yml down -v

License

This project is licensed under the MIT License. See the LICENSE file for details.