diff --git a/.gitattributes b/.gitattributes index 4e9e2f07..e9e63eb9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -23,3 +23,4 @@ /package.json export-ignore /package-lock.json export-ignore /testbench.yaml export-ignore +/docker-compose.integration.yml export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05f1639f..c5cc3f69 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,6 +74,161 @@ jobs: retention-days: 14 if-no-files-found: warn + integration: + name: Integration (${{ matrix.lane }}) + runs-on: ubuntu-latest + # A lock wait that never times out must fail the lane, not hold a runner for six hours. + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + include: + - lane: pgsql + image: postgres:16 + port: 5432 + extensions: pdo_pgsql + dsn-variable: FIREFLY_PG_DSN + dsn: host=127.0.0.1;port=5432;dbname=firefly;user=firefly;password=firefly + health: pg_isready -U firefly -d firefly + - lane: mysql + image: mysql:8.4 + port: 3306 + extensions: pdo_mysql + dsn-variable: FIREFLY_MYSQL_DSN + dsn: host=127.0.0.1;port=3306;dbname=firefly;user=firefly;password=firefly + health: mysqladmin ping -h 127.0.0.1 -ufirefly -pfirefly + - lane: mariadb + image: mariadb:11 + port: 3306 + extensions: pdo_mysql + dsn-variable: FIREFLY_MARIADB_DSN + dsn: host=127.0.0.1;port=3306;dbname=firefly;user=firefly;password=firefly + health: healthcheck.sh --connect --innodb_initialized + - lane: sqlsrv + image: mcr.microsoft.com/mssql/server:2022-latest + port: 1433 + extensions: pdo_sqlsrv, sqlsrv + dsn-variable: FIREFLY_SQLSRV_DSN + dsn: host=127.0.0.1;port=1433;dbname=firefly;user=sa;password=Firefly-Integration-1;encrypt=yes;trust_server_certificate=true + health: /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P Firefly-Integration-1 -Q 'SELECT 1' + services: + database: + image: ${{ matrix.image }} + # One env block serves every image: each reads only its own variables. + env: + POSTGRES_DB: firefly + POSTGRES_USER: firefly + POSTGRES_PASSWORD: firefly + MYSQL_DATABASE: firefly + MYSQL_USER: firefly + MYSQL_PASSWORD: firefly + MYSQL_ROOT_PASSWORD: firefly-root + MARIADB_DATABASE: firefly + MARIADB_USER: firefly + MARIADB_PASSWORD: firefly + MARIADB_ROOT_PASSWORD: firefly-root + ACCEPT_EULA: 'Y' + MSSQL_SA_PASSWORD: Firefly-Integration-1 + ports: + - ${{ matrix.port }}:${{ matrix.port }} + options: >- + --health-cmd "${{ matrix.health }}" + --health-interval 10s + --health-timeout 5s + --health-retries 30 + steps: + - uses: actions/checkout@v4 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + tools: composer:v2 + extensions: ${{ matrix.extensions }} + - name: Install dependencies + run: composer install --no-interaction --prefer-dist + - name: Install the Microsoft ODBC driver (pdo_sqlsrv needs it) + if: matrix.lane == 'sqlsrv' + run: | + curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | sudo gpg --batch --yes --dearmor -o /usr/share/keyrings/microsoft-prod.gpg + echo "deb [arch=amd64 signed-by=/usr/share/keyrings/microsoft-prod.gpg] https://packages.microsoft.com/ubuntu/$(lsb_release -rs)/prod $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/mssql-release.list + sudo apt-get update + sudo ACCEPT_EULA=Y apt-get install -y msodbcsql18 + - name: Create the sqlsrv database + if: matrix.lane == 'sqlsrv' + run: docker exec ${{ job.services.database.id }} /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P Firefly-Integration-1 -Q "CREATE DATABASE firefly" + - name: Pest (integration, ${{ matrix.lane }}) + env: + FIREFLY_INTEGRATION_LANE: ${{ matrix.lane }} + run: | + export ${{ matrix.dsn-variable }}='${{ matrix.dsn }}' + composer test:integration + + brokers: + name: Integration (brokers) + runs-on: ubuntu-latest + timeout-minutes: 25 + services: + rabbitmq: + image: rabbitmq:3.13-alpine + ports: + - 5672:5672 + options: >- + --health-cmd "rabbitmq-diagnostics -q ping" + --health-interval 10s + --health-timeout 10s + --health-retries 20 + kafka: + image: apache/kafka:3.8.0 + env: + KAFKA_NODE_ID: 1 + KAFKA_PROCESS_ROLES: broker,controller + KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://127.0.0.1:9092 + KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT + KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9093 + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 + KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0 + ports: + - 9092:9092 + options: >- + --health-cmd "/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server 127.0.0.1:9092" + --health-interval 10s + --health-timeout 10s + --health-retries 20 + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 5s + --health-retries 20 + steps: + - uses: actions/checkout@v4 + - name: Install librdkafka + run: sudo apt-get update && sudo apt-get install -y librdkafka-dev + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + tools: composer:v2 + extensions: rdkafka + - name: Install dependencies + run: composer install --no-interaction --prefer-dist + - name: Pest (integration, brokers) + env: + FIREFLY_INTEGRATION_LANE: brokers + FIREFLY_RABBITMQ_DSN: host=127.0.0.1;port=5672;user=guest;password=guest + FIREFLY_KAFKA_BROKERS: 127.0.0.1:9092 + FIREFLY_REDIS_URL: redis://127.0.0.1:6379/0 + run: composer test:integration + docs: name: Docs (site + bilingual books) runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f15508c..f61ff2e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,59 @@ All notable changes to LaraFly are documented here. This project uses CalVer (`Y ## [Unreleased] +### Added + +- **`firefly/eda-database` — the same-transaction outbox on every database.** `firefly.eda.provider=database` + writes the outbox row inside the aggregate's transaction on PostgreSQL, MySQL, MariaDB, SQL Server or SQLite, and + claims committed rows by leases through an `OutboxClaimStrategy` (SKIP LOCKED on PostgreSQL, MySQL 8.0.1+ and + MariaDB 10.6+, READPAST on SQL Server, a portable optimistic lease elsewhere — chosen from the server's version, + overridable by `firefly.eda.database.claim-strategy`). Any number of `firefly:eda:consume` workers and + `firefly:outbox:relay` processes may run against one outbox; two racing processes are proven to deliver every + row exactly once on each server. An `outbox` health component reports the driver, the strategy and the backlog. +- **Durable dead letters.** `firefly.eda.dead-letter.store=database` and `firefly.messaging.dead-letter.store=database` + keep exhausted envelopes and messages in `firefly_eda_dead_letters` / `firefly_messaging_dead_letters`, and + `firefly:eda:dead-letters:replay` / `firefly:messaging:dead-letters:replay` republish them (`--dry-run`, `--keep`, + selectors by id, destination, event type or topic), oldest first: the ids are time-ordered UUIDs (version 7), + monotonic within a process, so dead letters written in the same millisecond replay in the order they were written. +- **`firefly/scheduling-database`.** `firefly.scheduling.lock.provider=database` (`DatabaseLeaseLock`, a lease table + honouring the TTL on every driver) and `firefly.scheduling.lock.provider=mysql` (`MySqlNamedLock`, `GET_LOCK` on + MySQL and MariaDB). +- **Integration lanes.** `composer test:integration`; CI jobs `integration` (PostgreSQL 16, MySQL 8.4, MariaDB 11, + SQL Server 2022) and `brokers` (RabbitMQ, Kafka, Redis), each health-checked and exporting only its own DSN, with a + lane-sanity test that fails a misconfigured lane; `docker-compose.integration.yml` mirrors them locally. + `Firefly\Testing\Integration\IntegrationDsn` is the one parser every integration test uses (both DSN forms). +- **Data layer on real servers.** Exception translation (including deadlock victims), statement timeouts and + pessimistic/optimistic repository locking are integration-tested on every server; `DatabaseCapabilities` (in + `firefly/data`) reads SKIP LOCKED and READPAST support from the server version; the [database support + matrix](docs/database-support.md) documents the result. + +### Changed + +- **`firefly/eda-postgres` is the database outbox plus `pg_notify`/`LISTEN`.** `PostgresEventPublisher`, + `PostgresEventConsumer` and its `OutboxPreCommitHook` now extend the `firefly/eda-database` classes; + `provider=postgres` reads the `firefly.eda.database.*` keys, with every `firefly.eda.postgres.*` key kept as an + alias. The outbox table gains `claimed_by` / `claimed_until` (run `php artisan migrate`). +- **Transaction timeouts on SQL Server** set the session's `LOCK_TIMEOUT` and restore it afterwards. + +### Fixed + +- **Concurrent consumers could deliver an outbox row twice.** The Postgres consumer's `FOR UPDATE SKIP LOCKED` ran + outside any transaction, so its lock ended with the statement; on other drivers the claim was an unlocked read. + Every claim is now a lease committed before delivery. +- **`RabbitMqRoundTripTest` failed against a real broker when `FIREFLY_RABBITMQ_DSN` was a URL**, which the old + parser read as one key and silently replaced with `127.0.0.1:5672`. +- **The lumen sample's Postgres round trip called `firefly:eda:consume` without registering it.** +- **The CQRS/EDA bridge figure still credited only the Postgres adapter with same-transaction atomicity.** Its + caption now names the database outbox (`provider=database` or `postgres`), and its adapter row draws the + `database` provider; a test derives both providers from their outbox auto-configurations. + +### Deprecated + +- `Firefly\Eda\Postgres\Outbox\OutboxSchema`, `OutboxRow`, `OutboxRelay` (its `$useSkipLocked` argument now picks + `auto` or the optimistic lease), + `RelayDownstream` and `Firefly\Eda\Postgres\Console\OutboxRelayCommand` — use their `Firefly\Eda\Database\…` + namesakes; the old names remain as subclasses for one release. + ## [26.10.1] - 2026-10-06 ### Added diff --git a/README.md b/README.md index 5abf114c..dde5dc58 100644 --- a/README.md +++ b/README.md @@ -404,16 +404,18 @@ domain → integration-event loop with no glue code in application handlers: ### The genuine same-transaction outbox The default `memory`/`queue` `firefly/eda` providers publish only *after* the aggregate's transaction commits — -never atomically with it. `firefly/eda-postgres` closes that gap: with `firefly.eda.provider=postgres`, the -outbox row is written **inside** the aggregate's own open transaction, on the aggregate's own connection, so it -commits or rolls back atomically with the aggregate — no dual-write, no "commit then hope the publish -succeeds": +never atomically with it. `firefly/eda-database` closes that gap on every database Laravel speaks: with +`firefly.eda.provider=database`, the outbox row is written **inside** the aggregate's own open transaction, on the +aggregate's own connection, on PostgreSQL, MySQL, MariaDB, SQL Server or SQLite — no dual-write, no "commit then +hope the publish succeeds" — and committed rows are claimed by leases, so any number of consumers can drain them. +`firefly.eda.provider=postgres` is the same outbox plus a `pg_notify` raised in that transaction, which wakes a +`LISTEN`-ing consumer the moment the row commits:

The genuine same-transaction outbox: an aggregate's transaction begins, the domain write and the outbox INSERT + pg_notify happen on the same connection inside the same transaction, and only on commit does Postgres deliver the NOTIFY to a LISTEN-ing consumer that drains the row to subscribed #[EventListener] handlers.

-See the [same-tx outbox showcase](#same-transaction-outbox--fireflyedaproviderpostgres) below for the config +See the [same-tx outbox showcase](#same-transaction-outbox--fireflyedaproviderdatabase-or-postgres) below for the config and the seam that makes this possible. ### The security filter chain @@ -709,11 +711,11 @@ model. **Highlights:** the in-memory/queue adapters, retry + `DeadLetterStore`, (in-process) and `#[EventListener]` (the broker bus) are two distinct surfaces — see [Event-Driven Architecture](docs/modules/eda.md). -### Same-transaction outbox — `firefly.eda.provider=postgres` +### Same-transaction outbox — `firefly.eda.provider=database` or `postgres` The reference configuration ships both halves already: the `provider` switch and, commented out, the outbox -block it turns on. Point the switch at `postgres` (`FIREFLY_EDA_PROVIDER=postgres`, or the literal string) and -uncomment the `postgres` section: +block it turns on. Point the switch at `database` (`FIREFLY_EDA_PROVIDER=database`) — or at `postgres` on +PostgreSQL, which reads the same block — and uncomment the `database` section: @@ -724,12 +726,15 @@ uncomment the `postgres` section: // … - // 'postgres' => [ - // 'connection' => 'pgsql', + // 'database' => [ + // 'connection' => 'mysql', // 'channel' => 'firefly_eda_events', - // 'max_attempts' => 3, + // 'max-attempts' => 3, + // 'claim-strategy' => env('FIREFLY_EDA_CLAIM_STRATEGY', 'auto'), + // 'lease-seconds' => 300, + // 'poll-interval-ms' => 500, // 'relay' => [ - // 'downstream_provider' => 'rabbitmq', + // 'downstream-provider' => 'rabbitmq', // ], // ], @@ -738,17 +743,18 @@ uncomment the `postgres` section: ``` ```bash -composer require firefly/eda-postgres php artisan migrate # creates firefly_eda_outbox -php artisan firefly:eda:consume # terminal in-process delivery, run as a long-lived worker +php artisan firefly:eda:consume # terminal in-process delivery; run as many workers as you like ``` No application code changes — the same `#[EventListener]` handlers and the same `EventPublisher::publish()` call sites now run against a durable, same-transaction outbox instead of memory/queue. The seam is a small `PreCommitEventHook` interface `firefly/data`'s `DomainEventDispatcher` calls (if bound) *before* the ordinary -after-commit dispatch — null by default, so installing the package without selecting `provider=postgres` is -fully inert. **Highlights:** `pg_notify`/`LISTEN` low-latency wake, `FOR UPDATE SKIP LOCKED` claiming, the -optional `firefly:outbox:relay` to a distinct downstream broker — see [EDA Brokers](docs/modules/eda-brokers.md). +after-commit dispatch — null by default, so selecting any other provider leaves it fully inert. **Highlights:** leased +claims chosen from the server's capabilities (SKIP LOCKED, READPAST or a portable conditional UPDATE) that keep +any number of workers from delivering a row twice, `pg_notify`/`LISTEN` on PostgreSQL, the optional +`firefly:outbox:relay` to a distinct downstream broker — see [EDA Database Outbox](docs/modules/eda-database.md) +and the [database support matrix](docs/database-support.md). ### Transactions — `#[Transactional]` money-can't-vanish transfer @@ -1066,7 +1072,7 @@ Full flag reference and generated-file contents: [CLI](docs/cli.md). One published library, `fireflyframework/larafly`, contains the components under `packages/*` and the bundled application skeleton. Its `replace` entries satisfy component requirements at the framework's version; -component names remain useful module boundaries, with their own test suites. The 33 +component names remain useful module boundaries, with their own test suites. The 35 [module guides](docs/modules/) below group them by concern: | Group | Module | Package(s) | @@ -1081,13 +1087,15 @@ component names remain useful module boundaries, with their own test suites. The | Web & API | [Web Filters](docs/modules/web-filters.md) — the ordered filter chain onto Laravel middleware | `firefly/web` | | Web & API | [OpenAPI](docs/modules/openapi.md) — OpenAPI 3.1 generated from the compiled manifests, `firefly:openapi`, official Swagger UI from your own origin | `firefly/openapi` | | Resilience & Scheduling | [Resilience](docs/modules/resilience.md) — retry, circuit breaker, bulkhead, timeout, rate limiter, fallback | `firefly/resilience` | -| Resilience & Scheduling | [Scheduling](docs/modules/scheduling.md) — `#[Scheduled]` + distributed locks (cache or Postgres advisory) | `firefly/scheduling`, `firefly/scheduling-postgres` | +| Resilience & Scheduling | [Scheduling](docs/modules/scheduling.md) — `#[Scheduled]` + distributed locks (cache, database lease, MySQL named, Postgres advisory) | `firefly/scheduling`, `firefly/scheduling-postgres`, `firefly/scheduling-database` | +| Resilience & Scheduling | [Scheduling Database Locks](docs/modules/scheduling-database.md) — lease-table and MySQL named-lock `DistributedLock` providers | `firefly/scheduling-database` | | Data & Domain | [Domain (DDD)](docs/modules/domain.md) — `Entity`, `ValueObject`, `AggregateRoot`, `DomainEvent` | `firefly/domain` | | Data & Domain | [Data & Repositories](docs/modules/data.md) — CRUD/paging ports, derived queries, query by example, `#[Modifying]`/`#[Projection]`/`#[Lock]`/`#[EntityGraph]`, slices, exception translation | `firefly/data` | | Data & Domain | [Relational Data](docs/modules/data-relational.md) — `EloquentRepository`, soft-delete, auditing, optimistic and pessimistic locking | `firefly/data` | | Data & Domain | [Transactions](docs/modules/transactional.md) — `#[Transactional]`, propagation, isolation, timeouts, `#[TransactionalEventListener]` | `firefly/data` | | Eventing & Messaging | [Event-Driven Architecture](docs/modules/eda.md) — `EventPublisher`, `#[EventListener]`, retry/DLQ | `firefly/eda` | -| Eventing & Messaging | [EDA Brokers](docs/modules/eda-brokers.md) — RabbitMQ/Postgres/Kafka adapters, the same-tx outbox | `firefly/eda-rabbitmq`, `firefly/eda-postgres`, `firefly/eda-kafka` | +| Eventing & Messaging | [EDA Brokers](docs/modules/eda-brokers.md) — RabbitMQ/Postgres/Kafka adapters; Postgres adds `LISTEN`/`NOTIFY` to the database outbox | `firefly/eda-rabbitmq`, `firefly/eda-postgres`, `firefly/eda-kafka` | +| Eventing & Messaging | [EDA Database Outbox](docs/modules/eda-database.md) — the portable same-transaction outbox on every database, concurrent consumers | `firefly/eda-database` | | Eventing & Messaging | [Messaging](docs/modules/messaging.md) — the lower-level raw-bytes broker layer | `firefly/messaging` | | CQRS | [CQRS](docs/modules/cqrs.md) — `CommandBus`/`QueryBus`, the domain→integration-event bridge | `firefly/cqrs` | | Security | [Security](docs/modules/security.md) — principal model, `HttpSecurity`, `#[PreAuthorize]`, JWT/OAuth2 | `firefly/security` | @@ -1137,13 +1145,10 @@ HTTP endpoint to CQRS, the transactional outbox, method security, and the zero-r LaraFly's Foundation milestones (M1 through M14 — kernel, container, config, context, autoconfigure, validation, web, resilience, scheduling, data/domain, eda/messaging, cqrs, security, actuator/observability, testing, and the CLI/metapackage/skeleton) are complete, followed by real message-broker adapters and the -genuine same-transaction outbox (RabbitMQ, Postgres, Kafka) and the global `firefly new` installer. What's -still ahead, accurately: +genuine same-transaction outbox (RabbitMQ, Postgres, Kafka, and the portable database outbox on every SQL +server, with integration lanes per server in CI) and the global `firefly new` installer. What's still ahead, +accurately: -- **Broker integration spot-runs in CI.** Each broker adapter ships a genuine `@group('integration')` - round-trip test (RabbitMQ, Postgres, Kafka) gated behind Docker + a live connection string — these run - on demand, not in the default `vendor/bin/pest`/CI pass; wiring a scheduled/opt-in CI lane for them is - still open. - **More actuator endpoints.** Sixteen ship today — `/health`, `/info`, `/env`, `/beans`, `/conditions`, `/mappings`, `/loggers`, `/scheduledtasks`, `/caches`, `/configprops`, `/metrics`, `/prometheus`, `/process`, `/httpexchanges`, `/oauth2clients` when the authorization server is installed, and `/flags` diff --git a/book/art/diagram-branding.json b/book/art/diagram-branding.json index 8776c95b..698813ff 100644 --- a/book/art/diagram-branding.json +++ b/book/art/diagram-branding.json @@ -23,7 +23,7 @@ ] }, "docs/assets/diagrams/cqrs-eda-bridge.svg": { - "structure": "679ecd3e23e10c0dd19c97d6000e0bc927bc68f655645538ae9572d396452079", + "structure": "94d96da10329bd64b2022c26a9c0d15425f3717d8a1f7174332ed5ec4880a70a", "originalViewBox": "0 0 1200 820", "mirrors": [ "book/art/figures/cqrs-eda-bridge.svg" @@ -51,7 +51,7 @@ ] }, "docs/assets/diagrams/outbox-flow.svg": { - "structure": "56c906d58240aa387b8a663e6ecdae9ffe067e4a6f0d0c14e97044f9dae07a03", + "structure": "7482a4c154035cf4c7149cffdbc7d68f981e095ef024732059ecf09396200cde", "originalViewBox": "0 0 1200 1080", "mirrors": [ "book/art/figures/outbox-flow.svg" diff --git a/book/art/figures/cqrs-eda-bridge.svg b/book/art/figures/cqrs-eda-bridge.svg index 1ff7087e..60c1bc9a 100644 --- a/book/art/figures/cqrs-eda-bridge.svg +++ b/book/art/figures/cqrs-eda-bridge.svg @@ -70,27 +70,31 @@ - - memory + + memory - - queue + + queue - - rabbitmq + + rabbitmq - - postgres - (same-tx outbox) + + database + (same-tx outbox) - - kafka + + postgres + (same-tx outbox) + + + kafka Fires for ANY committed aggregate — not only CommandBus writes. The bridge listens on the in-process dispatcher, not on a specific command's aggregate. - Non-atomic here: the DB commit and the broker publish are two steps with a gap — true same-tx atomicity is the postgres outbox adapter (see outbox-flow.svg). + Non-atomic here: the DB commit and the broker publish are two steps with a gap — true same-tx atomicity is the database outbox (provider=database or postgres; see outbox-flow.svg). diff --git a/book/art/figures/outbox-flow.svg b/book/art/figures/outbox-flow.svg index 9bbeee8b..34b2372c 100644 --- a/book/art/figures/outbox-flow.svg +++ b/book/art/figures/outbox-flow.svg @@ -1,6 +1,6 @@ The genuine same-transaction outbox - Aggregate transaction writes the outbox row in-tx; a separate terminal consumer claims PENDING rows with FOR UPDATE SKIP LOCKED. + Aggregate transaction writes the outbox row in-tx; separate consumers lease PENDING rows (SKIP LOCKED on pgsql) and deliver them. @@ -11,7 +11,7 @@ The genuine same-transaction outbox packages/data/src/{Transaction/TransactionTemplate.php,Domain/DomainEventDispatcher.php} - packages/eda-postgres/src/{Outbox/OutboxPreCommitHook.php,PostgresEventPublisher.php,PostgresEventConsumer.php} + packages/eda-database/src/{Outbox/OutboxPreCommitHook.php,DatabaseEventConsumer.php} · packages/eda-postgres/src/{PostgresEventPublisher.php,PostgresEventConsumer.php} @@ -81,14 +81,14 @@ - poll(timeoutMs): NotificationWaiter waits (best-effort) for the queued NOTIFY — - delivered exactly when the row's commit lands + poll(timeoutMs): claims first; only when nothing is claimable does NotificationWaiter + wait (best-effort) for the NOTIFY delivered when the row's commit lands, then claim again - claim: SELECT * FROM firefly_eda_outbox WHERE status='PENDING' - ORDER BY id FOR UPDATE SKIP LOCKED (pgsql) + claim (a lease): firefly_eda_outbox rows WHERE status='PENDING' AND lease free + ORDER BY id · SKIP LOCKED / READPAST / conditional UPDATE → claimed_by, claimed_until @@ -101,16 +101,16 @@ success → ack(): UPDATE … SET status='PUBLISHED' - WHERE id=? AND status='PENDING' (idempotent) + WHERE id=? AND status='PENDING' AND claimed_by=me (idempotent) throw → nack(): attempts+1 → back to PENDING (retry) - or, past max_attempts, status='FAILED' + or, past max-attempts, status='FAILED' No EventPublisher::publish() call anywhere in this consumer path — it reads/updates the outbox table directly: no re-INSERT, no self-reference. - A single consumer worker gives exactly-once in-process delivery; multiple workers degrade to at-least-once (handlers should be idempotent). + Each row is leased to one consumer while it is delivered: any number of workers; at-least-once only across a crash. diff --git a/book/src-es/08-eda-outbox.md b/book/src-es/08-eda-outbox.md index 581f52c4..e026b7c2 100644 --- a/book/src-es/08-eda-outbox.md +++ b/book/src-es/08-eda-outbox.md @@ -392,11 +392,11 @@ interface PreCommitEventHook } ``` -Sin ningún hook vinculado — sin `firefly/eda-postgres`, o con cualquier proveedor que no sea `postgres` — esto es un no-op completo y `DomainEventDispatcher` se comporta exactamente como describió el Capítulo 6. `OutboxPreCommitHook` de `firefly/eda-postgres` es la implementación que lo activa: +Sin ningún hook vinculado — con cualquier proveedor que no sea `database` ni `postgres` — esto es un no-op completo y `DomainEventDispatcher` se comporta exactamente como describió el Capítulo 6. `OutboxPreCommitHook` de `firefly/eda-database` es la implementación que lo activa, con ambos proveedores de outbox: - + ```php -final class OutboxPreCommitHook implements PreCommitEventHook +class OutboxPreCommitHook implements PreCommitEventHook { // … public function handle(object $event, ?string $connection = null): void @@ -404,22 +404,31 @@ final class OutboxPreCommitHook implements PreCommitEventHook if (! $event instanceof DomainEvent) { return; } - // … - $emitNotify = $conn instanceof Connection && $conn->getDriverName() === 'pgsql'; - // … + + // The aggregate's connection — the one carrying the open transaction. + $publisher = $this->writerFor($this->connections->connection($connection)); + (new EdaCommandEventPublisher($publisher, $this->defaultDestination, $this->destinations, $this->correlation)) ->publish($event); } + + /** The writer for one event, on the aggregate's own connection. */ + protected function writerFor(ConnectionInterface $connection): DatabaseEventPublisher + { + return new DatabaseEventPublisher($connection, $this->registry, $this->channel, $this->tracing); + } } ``` +El hook homónimo de `firefly/eda-postgres` solo sobrescribe `writerFor()`, devolviendo un `PostgresEventPublisher` que además notifica sobre una conexión `pgsql`. + `$connection` se propaga sin cambios desde el dispatcher, así que la fila del outbox de un agregado `#[Transactional(connection: 'x')]` aterriza en la conexión `x` — nunca un valor por defecto fijo — y el hook reutiliza el *mismo* mapeo de `EdaCommandEventPublisher` (tipo de evento, carga útil, destino, id de correlación) que usa el puente post-confirmación, así que la fila que esto escribe es idéntica byte a byte en forma a lo que habría producido el puente ordinario. ### El INSERT, y `pg_notify` en la misma transacción - + ```php -final class PostgresEventPublisher implements EventPublisher +class DatabaseEventPublisher implements EventPublisher, OutboxWriter { // … public function publish(string $destination, string $eventType, array $payload, array $headers = []): void @@ -437,22 +446,34 @@ final class PostgresEventPublisher implements EventPublisher 'created_at' => $this->connection->raw('CURRENT_TIMESTAMP'), ]); - if ($this->emitNotify) { - // In-tx pg_notify: Postgres queues it and delivers on COMMIT, so the consumer's LISTEN wakes exactly - // … - $this->connection->statement('SELECT pg_notify(?, ?)', [$this->channel, (string) $id]); - } + $this->afterInsert($id); }); } // … } ``` -Como `publish()` se ejecuta sobre **cualquiera que sea la conexión con la que se construyó**, y esa conexión está en medio de una transacción cuando `OutboxPreCommitHook` la llama, el `INSERT` se alista en la propia transacción del agregado: confirma cuando el agregado confirma, y revierte cuando el agregado revierte. Cuando el driver es `pgsql`, el mismo closure también dispara `SELECT pg_notify(?, ?)` — **dentro de la misma transacción**. Postgres encola un `NOTIFY` lanzado dentro de una transacción y solo lo entrega de verdad una vez que esa transacción confirma, así que un consumidor a la escucha se despierta con baja latencia en el instante exacto en que la fila se vuelve visible para otras conexiones — y nunca se despierta para una fila que termina revertida. + +```php +final class PostgresEventPublisher extends DatabaseEventPublisher +{ + // … + protected function afterInsert(int $id): void + { + if ($this->emitNotify) { + // In-tx pg_notify: Postgres queues it and delivers on COMMIT, so the consumer's LISTEN wakes exactly when + // the PENDING row becomes visible. (sqlite and every other driver: emitNotify=false.) + $this->connection->statement('SELECT pg_notify(?, ?)', [$this->channel, (string) $id]); + } + } +} +``` + +Como `publish()` se ejecuta sobre **cualquiera que sea la conexión con la que se construyó**, y esa conexión está en medio de una transacción cuando `OutboxPreCommitHook` la llama, el `INSERT` se alista en la propia transacción del agregado: confirma cuando el agregado confirma, y revierte cuando el agregado revierte. Con `firefly.eda.provider=postgres` el escritor es `PostgresEventPublisher`, cuyo `afterInsert()` — invocado desde el mismo closure — lanza además `SELECT pg_notify(?, ?)` **dentro de la misma transacción**; con `provider=database` no hay NOTIFY y el consumidor sondea. Postgres encola un `NOTIFY` lanzado dentro de una transacción y solo lo entrega de verdad una vez que esa transacción confirma, así que un consumidor a la escucha se despierta con baja latencia en el instante exacto en que la fila se vuelve visible para otras conexiones — y nunca se despierta para una fila que termina revertida. Ese cuerpo entero vive dentro de `EdaTracing::tracePublish()` — el puerto único por el que pasan ahora los tres publicadores de broker, `eda-rabbitmq` y `eda-kafka` junto a este. Como propagar significa *escribir* algo en el sobre, el puerto le entrega al closure de envío las cabeceras que debe llevar y el adaptador construye la fila a partir de ellas, así que el `traceparent` que estampa el span productor va en la fila que confirma con el agregado — este es el único adaptador donde el span productor y el registro duradero son genuinamente atómicos, en lugar de un span que termina en cuanto un socket aceptó el mensaje. Con el trazado apagado, el `EdaTracing` vinculado es `NoOpEdaTracing`, que llama al closure con las cabeceras del llamador sin cambios, así que el camino es byte a byte el que ya era; y `transaction_id` se sigue leyendo de `x-correlation-id`, nunca de un traceparent, así que la clave de idempotencia del relay no cambia solo porque una petición resultara estar trazada. -La tabla del outbox (`firefly_eda_outbox`, un único esquema compartido por la migración, el publicador, el consumidor y el relay) lleva: `id, destination, channel, event_type, payload, headers, transaction_id, status, attempts, error_message, created_at, processed_at, failed_at`, con `status` siendo uno de `PENDING` / `PUBLISHED` / `FAILED`. +La tabla del outbox (`firefly_eda_outbox`, un único esquema compartido por la migración, el publicador, el consumidor y el relay) lleva: `id, destination, channel, event_type, payload, headers, transaction_id, status, attempts, error_message, created_at, processed_at, failed_at, claimed_by, claimed_until` — las dos últimas son el arrendamiento de la reclamación —, con `status` siendo uno de `PENDING` / `PUBLISHED` / `FAILED`. Una prueba genuina y distribuida demuestra ambas mitades de la garantía directamente contra una transacción real: @@ -490,72 +511,66 @@ Ninguna cantidad de prosa sustituye a esa segunda prueba: la fila del outbox de ### Evitar la doble publicación -Con `provider=postgres` vinculado, tanto `OutboxPreCommitHook` (en-tx) *como* el `DomainEventBridge` post-confirmación ordinario podrían, en principio, intentar escribir el mismo evento en el outbox. `PostgresOutboxAutoConfiguration` lo evita en `#[Order(900)]` — por delante de los valores por defecto `#[Order(1000)]` de `firefly/data` y `firefly/cqrs` — vinculando, en conjunto, como un único paquete: +Con `provider=postgres` vinculado, tanto `OutboxPreCommitHook` (en-tx) *como* el `DomainEventBridge` post-confirmación ordinario podrían, en principio, intentar escribir el mismo evento en el outbox. `PostgresOutboxAutoConfiguration` lo evita en `#[Order(900)]` (y `DatabaseOutboxAutoConfiguration` hace lo mismo con `provider=database`) — por delante de los valores por defecto `#[Order(1000)]` de `firefly/data` y `firefly/cqrs` — vinculando, en conjunto, como un único paquete: - su **propio** `DomainEventDispatcher`, portando el `OutboxPreCommitHook`, que gana el `#[ConditionalOnMissingBean(DomainEventDispatcher::class)]` de `DataAutoConfiguration`, y - un `NoOpEventPublisher` como el `CommandEventPublisher`, que gana el `#[ConditionalOnMissingBean(CommandEventPublisher::class)]` de `CqrsAutoConfiguration` — silenciando **solo** el tramo post-confirmación de eda. -El resultado: un evento de dominio se escribe en el outbox **exactamente una vez**, en-tx, de forma atómica junto con el agregado. El despacho post-confirmación genérico (no de eda) `#[AsEventListener]` con el que abrió este capítulo queda completamente intacto — esos listeners en proceso siguen disparándose exactamente como antes. Los cuatro beans están además protegidos por `#[ConditionalOnProperty('firefly.eda.provider', 'postgres')]`, así que instalar el paquete sin seleccionarlo como proveedor activo es totalmente inerte. +El resultado: un evento de dominio se escribe en el outbox **exactamente una vez**, en-tx, de forma atómica junto con el agregado. El despacho post-confirmación genérico (no de eda) `#[AsEventListener]` con el que abrió este capítulo queda completamente intacto — esos listeners en proceso siguen disparándose exactamente como antes. Los cuatro beans están además protegidos por el valor de su proveedor (`#[ConditionalOnProperty(name: 'firefly.eda.provider', havingValue: 'postgres')]`, o `'database'`), así que instalar el paquete sin seleccionarlo como proveedor activo es totalmente inerte. -### Reclamar filas: `PostgresEventConsumer` +### Reclamar filas: `DatabaseEventConsumer` -Una vez que una fila es `PENDING`, `PostgresEventConsumer` es la ruta de entrega en proceso terminal, siempre disponible — `php artisan firefly:eda:consume` lo conduce como un worker de larga duración: +Una vez que una fila es `PENDING`, `DatabaseEventConsumer` (de `firefly/eda-database`) es la ruta de entrega en proceso terminal, siempre disponible — `php artisan firefly:eda:consume` lo conduce como un worker de larga duración, igual con `provider=database` que con `provider=postgres`: - + ```php -final class PostgresEventConsumer implements EventConsumer +class DatabaseEventConsumer implements EventConsumer { - // … - public function subscribe(array $destinations): void - { - if ($this->connection->getDriverName() === 'pgsql') { - $this->connection->getPdo()->exec('LISTEN '.$this->channel); - } - } // … public function poll(int $timeoutMs): ?ReceivedEnvelope { - try { - ($this->awaitNotification)($timeoutMs); - } catch (\Throwable) { - // … + $received = $this->claimOne(); + if ($received !== null) { + return $received; } - $pgsql = $this->connection->getDriverName() === 'pgsql'; - $query = $this->connection->table(OutboxSchema::TABLE) - ->where('status', OutboxSchema::STATUS_PENDING) - ->orderBy('id'); - if ($pgsql) { - // … - } - $row = $query->first(); - - if ($row === null) { - return null; + try { + ($this->awaitWork)($timeoutMs); + } catch (Throwable) { + // The wait is an optimization; the claim below delivers regardless. } - return new ReceivedEnvelope(new EventEnvelope( - OutboxRow::asString($row->event_type), - OutboxRow::asString($row->destination), - OutboxRow::asMap($row->payload), - OutboxRow::asStringMap($row->headers), - ), OutboxRow::asInt($row->id)); + return $this->claimOne(); } public function ack(ReceivedEnvelope $received): void { - $this->connection->table(OutboxSchema::TABLE) - ->where('id', $received->deliveryTag) - ->where('status', OutboxSchema::STATUS_PENDING) - // … + ClaimableRows::owned($this->connection, OutboxRow::asInt($received->deliveryTag), $this->claimant)->update([ + 'status' => OutboxSchema::STATUS_PUBLISHED, + 'processed_at' => $this->connection->raw('CURRENT_TIMESTAMP'), + 'claimed_by' => null, + 'claimed_until' => null, + ]); } // … } ``` -`poll()` primero espera brevemente (de mejor esfuerzo, protegido contra cualquier fallo) un `NOTIFY`, y luego — sin importar si llegó uno — reclama la fila `PENDING` más antigua con `FOR UPDATE SKIP LOCKED` en pgsql (un `ORDER BY id` sencillo bajo sqlite, para las pruebas). Fíjate en que **no hay ninguna llamada a `EventPublisher::publish()` en ningún lugar de esta clase** — lee y actualiza la tabla del outbox directamente, así que no hay un `INSERT` de vuelta ni riesgo de que un consumidor realimente su propia salida a la tabla de la que acaba de leer. `ConsumerLoop` entrega el envoltorio reclamado al mismo `SubscriberRegistry` al que ya se suscribieron tus manejadores de `#[EventListener]`, exactamente igual que hacen los adaptadores en memoria y de cola; el guardián `WHERE status = 'PENDING'` de `ack()` hace que la confirmación sea idempotente, y un lanzamiento del manejador llama en cambio a `nack()`, que incrementa `attempts` y o bien reinicia la fila a `PENDING` (reintento) o, pasado `firefly.eda.postgres.max_attempts` (3 por defecto), la marca `FAILED` in situ — no hay una tabla de carta muerta separada para Postgres; una fila `status='FAILED'` *es* la carta muerta, consultable en la misma tabla `firefly_eda_outbox`. +Una reclamación es un **arrendamiento**: `claimOne()` pide una fila a la `OutboxClaimStrategy` configurada, y la estrategia escribe `claimed_by` y `claimed_until` en ella y lo confirma *antes* de que corra el manejador. Cómo se elige la fila depende de lo que hable el servidor — `FOR UPDATE SKIP LOCKED` dentro de una transacción de reclamación corta en PostgreSQL, MySQL 8.0.1+ y MariaDB 10.6+, la pista de tabla `UPDLOCK, READPAST` en SQL Server, un `UPDATE … WHERE status = 'PENDING' AND claimed_until < now` condicional en todos los demás — pero toda estrategia termina con la fila arrendada a exactamente un consumidor. `ack()` y `nack()` solo liquidan una fila que este consumidor todavía tiene, así que un consumidor que sobrevivió a su arrendamiento no liquida nada. Fíjate en que **no hay ninguna llamada a `EventPublisher::publish()` en ningún lugar de esta clase**: lee y actualiza la tabla del outbox directamente, así que no hay un `INSERT` de vuelta. Un lanzamiento del manejador llama a `nack()`, que incrementa `attempts` y libera la fila para un reintento inmediato o, pasado `firefly.eda.database.max-attempts` (3 por defecto; `firefly.eda.postgres.max_attempts` es su alias), la marca `FAILED` in situ — una fila `status='FAILED'` *es* la carta muerta del outbox. + +Con `provider=postgres`, `PostgresEventConsumer` es esta clase más el despertar de Postgres — `LISTEN` al suscribirse, y una espera de `NOTIFY` en lugar de una pausa entre reclamaciones vacías: + + +```php + public function subscribe(array $destinations): void + { + if ($this->connection->getDriverName() === 'pgsql') { + $this->connection->getPdo()->exec('LISTEN '.$this->channel); + } + } +``` -Esta es una ventana de estado `PENDING`→`PUBLISHED` **duradera**, no una marca de agua alta en memoria: un consumidor reiniciado retoma en la fila `PENDING` más antigua todavía pendiente y nunca reproduce una fila ya `PUBLISHED`. Un único worker consumidor da entrega en proceso exactamente-una-vez; ejecutar más de uno a la vez degrada a al-menos-una-vez (escribe tus listeners de forma idempotente si lo haces). +Esta es una ventana de estado `PENDING`→`PUBLISHED` **duradera**, no una marca de agua alta en memoria: un consumidor reiniciado retoma en la fila reclamable más antigua y nunca reproduce una ya `PUBLISHED`. Pueden correr a la vez tantos workers consumidores como quieras, en cualquiera de las cinco bases de datos — cada fila se entrega una vez mientras su consumidor vive dentro de su arrendamiento (`firefly.eda.database.lease-seconds`, 300 por defecto); un consumidor que cae a mitad de un manejador deja su fila para reclamarla de nuevo cuando acaba el arrendamiento, así que a través de una caída la entrega es al-menos-una-vez y los listeners deberían seguir siendo idempotentes. !!! laravel "Paridad con Laravel" Laravel puro no tiene ningún patrón de outbox transaccional de primera parte en absoluto — el idioma que tendrías que construir a mano es una tabla `outbox` más un comando planificado que la sondea, sin ninguna ayuda del framework para mantener el `INSERT` dentro de la misma transacción que tu escritura de dominio. `firefly/eda-postgres` es exactamente ese idioma, pero con la atomicidad garantizada por construcción (`PreCommitEventHook` se ejecuta mientras la transacción sigue abierta) en vez de dejada a la disciplina del desarrollador. @@ -573,7 +588,7 @@ Esta es una ventana de estado `PENDING`→`PUBLISHED` **duradera**, no una marca | `InMemoryEventBus` / `QueueEventBus` | Adaptadores `EventPublisher` síncrono frente a respaldado-por-cola (disparar-y-olvidar), puerto idéntico | | `DomainEventBridge` | El disparador post-confirmación que republica un `DomainEvent` confirmado como evento de integración — de mejor esfuerzo, no atómico | | `OutboxPreCommitHook` / `PostgresEventPublisher` | Escribe la fila del outbox **dentro** de la propia transacción abierta del agregado — genuinamente atómico | -| `PostgresEventConsumer` | Reclama filas `PENDING` (`FOR UPDATE SKIP LOCKED` + `LISTEN`/`NOTIFY`), conduce directamente los manejadores de `#[EventListener]`, sin `INSERT` de vuelta | +| `DatabaseEventConsumer` / `PostgresEventConsumer` | Arrienda filas `PENDING` (SKIP LOCKED, READPAST o un UPDATE condicional — cualquier número de workers), conduce directamente los manejadores de `#[EventListener]`, sin `INSERT` de vuelta; Postgres añade `LISTEN`/`NOTIFY` | --- diff --git a/book/src-es/09-transactions.md b/book/src-es/09-transactions.md index 0fcc240a..b18aff6d 100644 --- a/book/src-es/09-transactions.md +++ b/book/src-es/09-transactions.md @@ -318,7 +318,7 @@ if ($deadline !== null && (int) hrtime(true) > $deadline) { Fíjate en lo que eso no puede hacer: PHP no va a interrumpir un `SELECT` que sigue dentro del driver. La comprobación del reloj de pared juzga al método *después* de que retorne, lo que atrapa un exceso pero no lo detiene. -**El tiempo límite de sentencia del driver** es la mitad que sí puede. Inmediatamente después del `beginTransaction()` de la transacción más externa, `StatementTimeoutApplier` le dice a la propia base de datos que se rinda, en el dialecto que esa base de datos hable — `SET LOCAL statement_timeout` en pgsql (con ámbito de transacción, que es precisamente por lo que debe emitirse después del `BEGIN`), `max_execution_time` más `innodb_lock_wait_timeout` en mysql, el `max_statement_time` propio de mariadb en mariadb, y el tiempo de espera por ocupado en sqlite, que es el único mando que sqlite tiene. `apply()` devuelve el paso que lo deshace, y la plantilla lo ejecuta en su `finally` — porque las variables de mysql son variables de *sesión*, y en una conexión persistente la petición siguiente heredaría si no el presupuesto de esta. +**El tiempo límite de sentencia del driver** es la mitad que sí puede. Inmediatamente después del `beginTransaction()` de la transacción más externa, `StatementTimeoutApplier` le dice a la propia base de datos que se rinda, en el dialecto que esa base de datos hable — `SET LOCAL statement_timeout` en pgsql (con ámbito de transacción, que es precisamente por lo que debe emitirse después del `BEGIN`), `max_execution_time` más `innodb_lock_wait_timeout` en mysql, el `max_statement_time` propio de mariadb en mariadb, el tiempo de espera por ocupado en sqlite, que es el único mando que sqlite tiene, y `SET LOCK_TIMEOUT` en sqlsrv, que acota la espera por un bloqueo porque SQL Server no puede interrumpir una sentencia que simplemente es lenta. `apply()` devuelve el paso que lo deshace, y la plantilla lo ejecuta en su `finally` — porque las variables de mysql y el `LOCK_TIMEOUT` de SQL Server son ajustes de *sesión*, y en una conexión persistente la petición siguiente heredaría si no el presupuesto de esta. Cada una de esas sentencias es de mejor esfuerzo, exactamente igual que el `SET` de aislamiento: un driver que rechace una no hace fallar la transacción, y la comprobación del reloj de pared sigue aplicándose. Puedes apagar la mitad del driver por completo con `firefly.data.transaction.statement-timeout=false` y quedarte con el reloj de pared. diff --git a/book/src/08-eda-outbox.md b/book/src/08-eda-outbox.md index 06218bd7..493c5cff 100644 --- a/book/src/08-eda-outbox.md +++ b/book/src/08-eda-outbox.md @@ -392,11 +392,11 @@ interface PreCommitEventHook } ``` -With no hook bound — no `firefly/eda-postgres`, or any provider other than `postgres` — this is a complete no-op and `DomainEventDispatcher` behaves exactly as Chapter 6 described. `firefly/eda-postgres`'s `OutboxPreCommitHook` is the implementation that turns it on: +With no hook bound — any provider other than `database` or `postgres` — this is a complete no-op and `DomainEventDispatcher` behaves exactly as Chapter 6 described. `firefly/eda-database`'s `OutboxPreCommitHook` is the implementation that turns it on, under both outbox providers: - + ```php -final class OutboxPreCommitHook implements PreCommitEventHook +class OutboxPreCommitHook implements PreCommitEventHook { // … public function handle(object $event, ?string $connection = null): void @@ -404,22 +404,31 @@ final class OutboxPreCommitHook implements PreCommitEventHook if (! $event instanceof DomainEvent) { return; } - // … - $emitNotify = $conn instanceof Connection && $conn->getDriverName() === 'pgsql'; - // … + + // The aggregate's connection — the one carrying the open transaction. + $publisher = $this->writerFor($this->connections->connection($connection)); + (new EdaCommandEventPublisher($publisher, $this->defaultDestination, $this->destinations, $this->correlation)) ->publish($event); } + + /** The writer for one event, on the aggregate's own connection. */ + protected function writerFor(ConnectionInterface $connection): DatabaseEventPublisher + { + return new DatabaseEventPublisher($connection, $this->registry, $this->channel, $this->tracing); + } } ``` +`firefly/eda-postgres`'s hook of the same short name overrides only `writerFor()`, returning a `PostgresEventPublisher` that also notifies on a `pgsql` connection. + `$connection` is threaded through unchanged from the dispatcher, so a `#[Transactional(connection: 'x')]` aggregate's outbox row lands on connection `x` — never a hard-coded default — and the hook reuses the *exact same* `EdaCommandEventPublisher` mapping (event type, payload, destination, correlation id) the after-commit bridge uses, so the row this writes is byte-for-byte identical in shape to what the ordinary bridge would have produced. ### The INSERT, and `pg_notify` in the same transaction - + ```php -final class PostgresEventPublisher implements EventPublisher +class DatabaseEventPublisher implements EventPublisher, OutboxWriter { // … public function publish(string $destination, string $eventType, array $payload, array $headers = []): void @@ -437,22 +446,34 @@ final class PostgresEventPublisher implements EventPublisher 'created_at' => $this->connection->raw('CURRENT_TIMESTAMP'), ]); - if ($this->emitNotify) { - // In-tx pg_notify: Postgres queues it and delivers on COMMIT, so the consumer's LISTEN wakes exactly - // … - $this->connection->statement('SELECT pg_notify(?, ?)', [$this->channel, (string) $id]); - } + $this->afterInsert($id); }); } // … } ``` -Because `publish()` runs on **whatever connection it was constructed with**, and that connection is mid-transaction when `OutboxPreCommitHook` calls it, the `INSERT` enlists in the aggregate's own transaction: it commits when the aggregate commits, and it rolls back when the aggregate rolls back. When the driver is `pgsql`, the same closure also fires `SELECT pg_notify(?, ?)` — **inside the same transaction**. Postgres queues a `NOTIFY` raised inside a transaction and only actually delivers it once that transaction commits, so a listening consumer wakes with low latency at exactly the moment the row becomes visible to other connections — and never wakes for a row that gets rolled back. + +```php +final class PostgresEventPublisher extends DatabaseEventPublisher +{ + // … + protected function afterInsert(int $id): void + { + if ($this->emitNotify) { + // In-tx pg_notify: Postgres queues it and delivers on COMMIT, so the consumer's LISTEN wakes exactly when + // the PENDING row becomes visible. (sqlite and every other driver: emitNotify=false.) + $this->connection->statement('SELECT pg_notify(?, ?)', [$this->channel, (string) $id]); + } + } +} +``` + +Because `publish()` runs on **whatever connection it was constructed with**, and that connection is mid-transaction when `OutboxPreCommitHook` calls it, the `INSERT` enlists in the aggregate's own transaction: it commits when the aggregate commits, and it rolls back when the aggregate rolls back. Under `firefly.eda.provider=postgres` the writer is `PostgresEventPublisher`, whose `afterInsert()` — called from the same closure — also fires `SELECT pg_notify(?, ?)` **inside the same transaction**; under `provider=database` there is no NOTIFY and the consumer polls. Postgres queues a `NOTIFY` raised inside a transaction and only actually delivers it once that transaction commits, so a listening consumer wakes with low latency at exactly the moment the row becomes visible to other connections — and never wakes for a row that gets rolled back. The whole of that body sits inside `EdaTracing::tracePublish()` — the single port all three broker publishers now route through, `eda-rabbitmq` and `eda-kafka` alongside this one. Because propagation means *writing* something on the envelope, the port hands the send closure the headers to carry and the adapter builds the row out of those, so the `traceparent` the producer span stamps is on the row that commits with the aggregate — this is the one adapter where the producer span and the durable record are genuinely atomic, rather than a span that ends the moment a socket accepted the message. With tracing off the bound `EdaTracing` is `NoOpEdaTracing`, which calls the closure with the caller's headers unchanged, so the path is byte-for-byte what it was; and `transaction_id` is still read from `x-correlation-id`, never from a traceparent, so the relay's idempotency key does not change just because a request happened to be traced. -The outbox table (`firefly_eda_outbox`, one schema shared by the migration, the publisher, the consumer, and the relay) carries: `id, destination, channel, event_type, payload, headers, transaction_id, status, attempts, error_message, created_at, processed_at, failed_at`, with `status` one of `PENDING` / `PUBLISHED` / `FAILED`. +The outbox table (`firefly_eda_outbox`, one schema shared by the migration, the publisher, the consumer, and the relay) carries: `id, destination, channel, event_type, payload, headers, transaction_id, status, attempts, error_message, created_at, processed_at, failed_at, claimed_by, claimed_until` — the last two are the claim's lease — with `status` one of `PENDING` / `PUBLISHED` / `FAILED`. A genuine, shipped test proves both halves of the guarantee directly against a real transaction: @@ -490,72 +511,66 @@ No amount of prose substitutes for that second test: the outbox row from the fai ### Double-publish avoidance -With `provider=postgres` bound, both `OutboxPreCommitHook` (in-tx) *and* the ordinary after-commit `DomainEventBridge` could, in principle, both try to write the same event to the outbox. `PostgresOutboxAutoConfiguration` prevents that at `#[Order(900)]` — ahead of `firefly/data`'s and `firefly/cqrs`'s `#[Order(1000)]` defaults — by binding, together, as one package: +With `provider=postgres` bound, both `OutboxPreCommitHook` (in-tx) *and* the ordinary after-commit `DomainEventBridge` could, in principle, both try to write the same event to the outbox. `PostgresOutboxAutoConfiguration` prevents that at `#[Order(900)]` (and `DatabaseOutboxAutoConfiguration` does the same under `provider=database`) — ahead of `firefly/data`'s and `firefly/cqrs`'s `#[Order(1000)]` defaults — by binding, together, as one package: - its **own** `DomainEventDispatcher`, carrying the `OutboxPreCommitHook`, which wins `DataAutoConfiguration`'s `#[ConditionalOnMissingBean(DomainEventDispatcher::class)]`, and - a `NoOpEventPublisher` as the `CommandEventPublisher`, which wins `CqrsAutoConfiguration`'s `#[ConditionalOnMissingBean(CommandEventPublisher::class)]` — silencing **only** the after-commit eda leg. -The result: a domain event is written to the outbox **exactly once**, in-tx, atomically with the aggregate. The generic (non-eda) `#[AsEventListener]` after-commit dispatch this chapter opened with is completely untouched — those in-process listeners keep firing exactly as before. All four beans are additionally gated `#[ConditionalOnProperty('firefly.eda.provider', 'postgres')]`, so installing the package without selecting it as the active provider is fully inert. +The result: a domain event is written to the outbox **exactly once**, in-tx, atomically with the aggregate. The generic (non-eda) `#[AsEventListener]` after-commit dispatch this chapter opened with is completely untouched — those in-process listeners keep firing exactly as before. All four beans are additionally gated on their provider value (`#[ConditionalOnProperty(name: 'firefly.eda.provider', havingValue: 'postgres')]`, or `'database'`), so installing the package without selecting it as the active provider is fully inert. -### Claiming rows: `PostgresEventConsumer` +### Claiming rows: `DatabaseEventConsumer` -Once a row is `PENDING`, `PostgresEventConsumer` is the always-available, terminal in-process delivery path — `php artisan firefly:eda:consume` drives it as a long-running worker: +Once a row is `PENDING`, `DatabaseEventConsumer` (from `firefly/eda-database`) is the always-available, terminal in-process delivery path — `php artisan firefly:eda:consume` drives it as a long-running worker, under `provider=database` and `provider=postgres` alike: - + ```php -final class PostgresEventConsumer implements EventConsumer +class DatabaseEventConsumer implements EventConsumer { - // … - public function subscribe(array $destinations): void - { - if ($this->connection->getDriverName() === 'pgsql') { - $this->connection->getPdo()->exec('LISTEN '.$this->channel); - } - } // … public function poll(int $timeoutMs): ?ReceivedEnvelope { - try { - ($this->awaitNotification)($timeoutMs); - } catch (\Throwable) { - // … + $received = $this->claimOne(); + if ($received !== null) { + return $received; } - $pgsql = $this->connection->getDriverName() === 'pgsql'; - $query = $this->connection->table(OutboxSchema::TABLE) - ->where('status', OutboxSchema::STATUS_PENDING) - ->orderBy('id'); - if ($pgsql) { - // … - } - $row = $query->first(); - - if ($row === null) { - return null; + try { + ($this->awaitWork)($timeoutMs); + } catch (Throwable) { + // The wait is an optimization; the claim below delivers regardless. } - return new ReceivedEnvelope(new EventEnvelope( - OutboxRow::asString($row->event_type), - OutboxRow::asString($row->destination), - OutboxRow::asMap($row->payload), - OutboxRow::asStringMap($row->headers), - ), OutboxRow::asInt($row->id)); + return $this->claimOne(); } public function ack(ReceivedEnvelope $received): void { - $this->connection->table(OutboxSchema::TABLE) - ->where('id', $received->deliveryTag) - ->where('status', OutboxSchema::STATUS_PENDING) - // … + ClaimableRows::owned($this->connection, OutboxRow::asInt($received->deliveryTag), $this->claimant)->update([ + 'status' => OutboxSchema::STATUS_PUBLISHED, + 'processed_at' => $this->connection->raw('CURRENT_TIMESTAMP'), + 'claimed_by' => null, + 'claimed_until' => null, + ]); } // … } ``` -`poll()` first waits briefly (best-effort, guarded against any failure) on a `NOTIFY`, then — regardless of whether one arrived — claims the oldest `PENDING` row with `FOR UPDATE SKIP LOCKED` on pgsql (a plain `ORDER BY id` under sqlite, for tests). Note there is **no `EventPublisher::publish()` call anywhere in this class** — it reads and updates the outbox table directly, so there is no re-`INSERT` and no risk of a consumer feeding its own output back into the table it just read from. `ConsumerLoop` delivers the claimed envelope to the same `SubscriberRegistry` your `#[EventListener]` handlers already subscribed to, exactly as the in-memory and queue adapters do; `ack()`'s `WHERE status = 'PENDING'` guard makes acknowledgement idempotent, and a handler throw instead calls `nack()`, which increments `attempts` and either resets the row to `PENDING` (retry) or, past `firefly.eda.postgres.max_attempts` (default 3), marks it `FAILED` in place — there is no separate dead-letter table for Postgres; a `status='FAILED'` row *is* the dead letter, queryable on the same `firefly_eda_outbox` table. +A claim is a **lease**: `claimOne()` asks the configured `OutboxClaimStrategy` for one row, and the strategy writes `claimed_by` and `claimed_until` on it and commits that *before* the handler runs. How the row is picked depends on what the server speaks — `FOR UPDATE SKIP LOCKED` inside a short claim transaction on PostgreSQL, MySQL 8.0.1+ and MariaDB 10.6+, the `UPDLOCK, READPAST` table hint on SQL Server, a conditional `UPDATE … WHERE status = 'PENDING' AND claimed_until < now` everywhere else — but every strategy ends with the row leased to exactly one consumer. `ack()` and `nack()` settle only a row this consumer still holds, so a consumer that outlived its lease settles nothing. Note there is **no `EventPublisher::publish()` call anywhere in this class**: it reads and updates the outbox table directly, so there is no re-`INSERT`. A handler throw calls `nack()`, which increments `attempts` and frees the row for an immediate retry or, past `firefly.eda.database.max-attempts` (default 3; `firefly.eda.postgres.max_attempts` is its alias), marks it `FAILED` in place — a `status='FAILED'` row *is* the outbox's dead letter. + +Under `provider=postgres`, `PostgresEventConsumer` is this class plus Postgres's wake-up — `LISTEN` on subscribe, and a `NOTIFY` wait instead of a sleep between empty claims: + + +```php + public function subscribe(array $destinations): void + { + if ($this->connection->getDriverName() === 'pgsql') { + $this->connection->getPdo()->exec('LISTEN '.$this->channel); + } + } +``` -This is a **durable** `PENDING`→`PUBLISHED` status window, not an in-memory high-water mark: a restarted consumer resumes at the oldest still-`PENDING` row and never replays a `PUBLISHED` one. A single consumer worker gives exactly-once in-process delivery; running more than one concurrently degrades to at-least-once (write your listeners idempotently if you do). +This is a **durable** `PENDING`→`PUBLISHED` status window, not an in-memory high-water mark: a restarted consumer resumes at the oldest claimable row and never replays a `PUBLISHED` one. Any number of consumer workers may run at once, on any of the five databases — each row is delivered once while its consumer lives within its lease (`firefly.eda.database.lease-seconds`, 300 by default); a consumer that crashes mid-handler leaves its row to be claimed again when the lease ends, so across a crash delivery is at-least-once and listeners should still be idempotent. !!! laravel "Laravel parity" Plain Laravel has no first-party transactional-outbox pattern at all — the idiom you'd hand-roll is an `outbox` table plus a scheduled command that polls it, with no framework help keeping the `INSERT` inside the same transaction as your domain write. `firefly/eda-postgres` is exactly that idiom, but with the atomicity guaranteed by construction (`PreCommitEventHook` runs while the transaction is still open) rather than left to developer discipline. @@ -573,7 +588,7 @@ This is a **durable** `PENDING`→`PUBLISHED` status window, not an in-memory hi | `InMemoryEventBus` / `QueueEventBus` | Synchronous vs. queue-backed (fire-and-forget) `EventPublisher` adapters, identical port | | `DomainEventBridge` | The after-commit trigger republishing a committed `DomainEvent` as an integration event — best-effort, non-atomic | | `OutboxPreCommitHook` / `PostgresEventPublisher` | Writes the outbox row **inside** the aggregate's own open transaction — genuinely atomic | -| `PostgresEventConsumer` | Claims `PENDING` rows (`FOR UPDATE SKIP LOCKED` + `LISTEN`/`NOTIFY`), drives `#[EventListener]` handlers directly, no re-`INSERT` | +| `DatabaseEventConsumer` / `PostgresEventConsumer` | Leases `PENDING` rows (SKIP LOCKED, READPAST or a conditional UPDATE — any number of workers), drives `#[EventListener]` handlers directly, no re-`INSERT`; Postgres adds `LISTEN`/`NOTIFY` | --- diff --git a/book/src/09-transactions.md b/book/src/09-transactions.md index 736c2ae4..2c62741f 100644 --- a/book/src/09-transactions.md +++ b/book/src/09-transactions.md @@ -318,7 +318,7 @@ if ($deadline !== null && (int) hrtime(true) > $deadline) { Note what that cannot do: PHP is not going to interrupt a `SELECT` that is still inside the driver. The wall-clock check judges the method *after* it returns, which catches an overrun but does not stop one. -**The driver statement timeout** is the half that can. Immediately after `beginTransaction()` on the outermost transaction, `StatementTimeoutApplier` tells the database itself to give up, in whatever dialect that database speaks — `SET LOCAL statement_timeout` on pgsql (transaction-scoped, which is precisely why it must be issued after the `BEGIN`), `max_execution_time` plus `innodb_lock_wait_timeout` on mysql, mariadb's own `max_statement_time` on mariadb, and the busy timeout on sqlite, which is the only knob sqlite has. `apply()` returns the step that undoes it, and the template runs that in its `finally` — because the mysql variables are *session* variables, and on a persistent connection the next request would otherwise inherit this request's budget. +**The driver statement timeout** is the half that can. Immediately after `beginTransaction()` on the outermost transaction, `StatementTimeoutApplier` tells the database itself to give up, in whatever dialect that database speaks — `SET LOCAL statement_timeout` on pgsql (transaction-scoped, which is precisely why it must be issued after the `BEGIN`), `max_execution_time` plus `innodb_lock_wait_timeout` on mysql, mariadb's own `max_statement_time` on mariadb, the busy timeout on sqlite, which is the only knob sqlite has, and `SET LOCK_TIMEOUT` on sqlsrv, which bounds a lock wait because SQL Server cannot interrupt a statement that is merely slow. `apply()` returns the step that undoes it, and the template runs that in its `finally` — because the mysql variables and SQL Server's `LOCK_TIMEOUT` are *session* settings, and on a persistent connection the next request would otherwise inherit this request's budget. Every one of those statements is best-effort, exactly like the isolation `SET`: a driver that refuses one does not fail the transaction, and the wall-clock check still applies. You can turn the driver half off entirely with `firefly.data.transaction.statement-timeout=false` and keep the wall clock. diff --git a/composer.json b/composer.json index b0e106a3..ab8b13af 100644 --- a/composer.json +++ b/composer.json @@ -50,6 +50,7 @@ "pestphp/pest": "^4.5", "pestphp/pest-plugin-browser": "^4.3", "phpstan/phpstan": "^2.1", + "predis/predis": "^3.0", "symplify/monorepo-builder": "^11.2" }, "autoload-dev": { @@ -64,6 +65,7 @@ "Firefly\\Cqrs\\Tests\\": "packages/cqrs/tests/", "Firefly\\Data\\Tests\\": "packages/data/tests/", "Firefly\\Domain\\Tests\\": "packages/domain/tests/", + "Firefly\\Eda\\Database\\Tests\\": "packages/eda-database/tests/", "Firefly\\Eda\\Kafka\\Tests\\": "packages/eda-kafka/tests/", "Firefly\\Eda\\Postgres\\Tests\\": "packages/eda-postgres/tests/", "Firefly\\Eda\\Rabbitmq\\Tests\\": "packages/eda-rabbitmq/tests/", @@ -75,6 +77,7 @@ "Firefly\\Observability\\Tests\\": "packages/observability/tests/", "Firefly\\OpenApi\\Tests\\": "packages/openapi/tests/", "Firefly\\Resilience\\Tests\\": "packages/resilience/tests/", + "Firefly\\Scheduling\\Database\\Tests\\": "packages/scheduling-database/tests/", "Firefly\\Scheduling\\Postgres\\Tests\\": "packages/scheduling-postgres/tests/", "Firefly\\Scheduling\\Tests\\": "packages/scheduling/tests/", "Firefly\\Security\\Tests\\": "packages/security/tests/", @@ -108,6 +111,7 @@ "stan": "phpstan analyse --no-progress --memory-limit=1G", "test": "@php -d memory_limit=512M vendor/bin/pest", "test:browser": "@php -d memory_limit=512M vendor/bin/pest --testsuite=browser", + "test:integration": "@php -d memory_limit=1G vendor/bin/pest --group integration", "deptrac": "deptrac analyse --no-progress --config-file=deptrac.yaml", "mono-validate": "monorepo-builder validate", "check": [ @@ -130,6 +134,7 @@ "firefly/data": "self.version", "firefly/domain": "self.version", "firefly/eda": "self.version", + "firefly/eda-database": "self.version", "firefly/eda-kafka": "self.version", "firefly/eda-postgres": "self.version", "firefly/eda-rabbitmq": "self.version", @@ -142,6 +147,7 @@ "firefly/openapi": "self.version", "firefly/resilience": "self.version", "firefly/scheduling": "self.version", + "firefly/scheduling-database": "self.version", "firefly/scheduling-postgres": "self.version", "firefly/security": "self.version", "firefly/security-oauth2-client": "self.version", @@ -163,6 +169,7 @@ "Firefly\\Data\\": "packages/data/src/", "Firefly\\Domain\\": "packages/domain/src/", "Firefly\\Eda\\": "packages/eda/src/", + "Firefly\\Eda\\Database\\": "packages/eda-database/src/", "Firefly\\Eda\\Kafka\\": "packages/eda-kafka/src/", "Firefly\\Eda\\Postgres\\": "packages/eda-postgres/src/", "Firefly\\Eda\\Rabbitmq\\": "packages/eda-rabbitmq/src/", @@ -174,6 +181,7 @@ "Firefly\\OpenApi\\": "packages/openapi/src/", "Firefly\\Resilience\\": "packages/resilience/src/", "Firefly\\Scheduling\\": "packages/scheduling/src/", + "Firefly\\Scheduling\\Database\\": "packages/scheduling-database/src/", "Firefly\\Scheduling\\Postgres\\": "packages/scheduling-postgres/src/", "Firefly\\Security\\": "packages/security/src/", "Firefly\\Security\\OAuth2\\Client\\": "packages/security-oauth2-client/src/", @@ -241,6 +249,7 @@ "Firefly\\Cqrs\\CqrsWiringProvider", "Firefly\\Messaging\\MessagingServiceProvider", "Firefly\\Messaging\\MessagingWiringProvider", + "Firefly\\Messaging\\MessagingConsoleServiceProvider", "Firefly\\Observability\\ObservabilityServiceProvider", "Firefly\\Observability\\ObservabilityWiringProvider", "Firefly\\Security\\SecurityServiceProvider", @@ -248,12 +257,16 @@ "Firefly\\Security\\OAuth2\\Server\\SecurityOAuth2ServerServiceProvider", "Firefly\\Security\\OAuth2\\Server\\SecurityOAuth2ServerWiringProvider", "Firefly\\Cli\\CliServiceProvider", + "Firefly\\Eda\\Database\\EdaDatabaseServiceProvider", + "Firefly\\Eda\\Database\\EdaDatabaseBootServiceProvider", "Firefly\\Eda\\Kafka\\EdaKafkaServiceProvider", "Firefly\\Eda\\Postgres\\EdaPostgresServiceProvider", "Firefly\\Eda\\Postgres\\EdaPostgresBootServiceProvider", "Firefly\\Eda\\Rabbitmq\\EdaRabbitmqServiceProvider", "Firefly\\OpenApi\\OpenApiServiceProvider", "Firefly\\OpenApi\\OpenApiWiringProvider", + "Firefly\\Scheduling\\Database\\SchedulingDatabaseServiceProvider", + "Firefly\\Scheduling\\Database\\SchedulingDatabaseBootServiceProvider", "Firefly\\Scheduling\\Postgres\\SchedulingPostgresServiceProvider", "Firefly\\Security\\OAuth2\\Client\\SecurityOAuth2ClientServiceProvider", "Firefly\\Security\\OAuth2\\Client\\SecurityOAuth2ClientWiringProvider", diff --git a/deptrac.yaml b/deptrac.yaml index e1eab59b..fc71d056 100644 --- a/deptrac.yaml +++ b/deptrac.yaml @@ -42,6 +42,10 @@ deptrac: collectors: - type: directory value: packages/scheduling-postgres/src/.* + - name: SchedulingDatabase + collectors: + - type: directory + value: packages/scheduling-database/src/.* - name: Domain collectors: - type: directory @@ -62,6 +66,10 @@ deptrac: collectors: - type: directory value: packages/eda-postgres/src/.* + - name: EdaDatabase + collectors: + - type: directory + value: packages/eda-database/src/.* - name: EdaKafka collectors: - type: directory @@ -208,6 +216,19 @@ deptrac: - AutoConfigure - Scheduling + # SchedulingDatabase holds the database-backed adapters for Scheduling's DistributedLock port: a portable lease + # table (DatabaseLeaseLock) and MySQL/MariaDB named locks (MySqlNamedLock). Its own package for the same reason as + # SchedulingPostgres — the lock providers are opt-in backends, not part of the core scheduler. It reads Config, + # carries Container/Context attributes, extends AutoConfigure's base, reuses Kernel's ConfigurationException and + # implements Scheduling's DistributedLock. It is depended on by NONE. + SchedulingDatabase: + - Kernel + - Container + - Config + - Context + - AutoConfigure + - Scheduling + # Domain is the pure DDD base: Entity / ValueObject / AggregateRoot / DomainEvent. It carries no framework # dependency (Kernel is permitted but deliberately unused). NOTHING may depend on Domain except Data. Domain: @@ -259,6 +280,7 @@ deptrac: # mirror the after-commit domain->envelope mapping (=> Cqrs), mapping Domain's DomainEvent (=> Domain); it supplies # an Actuator HealthIndicator (=> Actuator). It reads Config indirectly via #[ConditionalOnProperty], carries # Container/Context attributes, and extends AutoConfigure's base. It is depended on by NONE. + # From W1 on it is the database outbox (=> EdaDatabase) plus pg_notify on write and a LISTEN wake-up. EdaPostgres: - Kernel - Container @@ -270,6 +292,25 @@ deptrac: - Eda - Cqrs - Actuator + - EdaDatabase + + # EdaDatabase is the PORTABLE same-transaction outbox for Eda's EventPublisher/EventConsumer ports, on any + # Laravel database connection. Its OutboxPreCommitHook implements Data's PreCommitEventHook (=> Data) and maps + # Domain's DomainEvent through Cqrs's EdaCommandEventPublisher (=> Domain, Cqrs); its claim strategies are chosen + # by Data's DatabaseCapabilities probe (=> Data); it supplies an Actuator HealthIndicator (=> Actuator). It reads + # Config, carries Container/Context attributes and extends AutoConfigure's base. Only EdaPostgres depends on it — + # the Postgres package is this outbox plus pg_notify/LISTEN. + EdaDatabase: + - Kernel + - Container + - Config + - Context + - AutoConfigure + - Domain + - Data + - Eda + - Cqrs + - Actuator # EdaKafka is the Kafka adapter for Eda's EventPublisher port over the OPTIONAL ext-rdkafka (suggest + # extension_loaded guard, never a hard require): its own package (every broker its own package, the diff --git a/docker-compose.integration.yml b/docker-compose.integration.yml new file mode 100644 index 00000000..897292e5 --- /dev/null +++ b/docker-compose.integration.yml @@ -0,0 +1,103 @@ +# Local backends for `composer test:integration` — the same images the CI `integration` and `brokers` jobs run. +# +# docker compose -f docker-compose.integration.yml up -d # everything but SQL Server +# docker compose -f docker-compose.integration.yml --profile sqlsrv up -d # + SQL Server (x86-64 image; emulated on arm64) +# export FIREFLY_PG_DSN='host=127.0.0.1;port=5432;dbname=firefly;user=firefly;password=firefly' +# export FIREFLY_MYSQL_DSN='host=127.0.0.1;port=3306;dbname=firefly;user=firefly;password=firefly' +# export FIREFLY_MARIADB_DSN='host=127.0.0.1;port=3307;dbname=firefly;user=firefly;password=firefly' +# export FIREFLY_SQLSRV_DSN='host=127.0.0.1;port=1433;dbname=master;user=sa;password=Firefly-Integration-1;encrypt=yes;trust_server_certificate=true' +# export FIREFLY_RABBITMQ_DSN='host=127.0.0.1;port=5672;user=guest;password=guest' +# export FIREFLY_KAFKA_BROKERS='127.0.0.1:9092' +# export FIREFLY_REDIS_URL='redis://127.0.0.1:6379/0' +# composer test:integration +# +# Every host port can be moved with the FIREFLY_IT_*_PORT variables when the defaults are taken; the Kafka advertised +# listener follows FIREFLY_IT_KAFKA_PORT. A missing variable skips its tests, it never fails them. +services: + pgsql: + image: postgres:16 + environment: + POSTGRES_DB: firefly + POSTGRES_USER: firefly + POSTGRES_PASSWORD: firefly + ports: ["${FIREFLY_IT_PG_PORT:-5432}:5432"] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U firefly -d firefly"] + interval: 5s + timeout: 3s + retries: 30 + mysql: + image: mysql:8.4 + environment: + MYSQL_DATABASE: firefly + MYSQL_USER: firefly + MYSQL_PASSWORD: firefly + MYSQL_ROOT_PASSWORD: firefly-root + ports: ["${FIREFLY_IT_MYSQL_PORT:-3306}:3306"] + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -ufirefly -pfirefly"] + interval: 5s + timeout: 3s + retries: 30 + mariadb: + image: mariadb:11 + environment: + MARIADB_DATABASE: firefly + MARIADB_USER: firefly + MARIADB_PASSWORD: firefly + MARIADB_ROOT_PASSWORD: firefly-root + ports: ["${FIREFLY_IT_MARIADB_PORT:-3307}:3306"] + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 5s + timeout: 3s + retries: 30 + sqlsrv: + image: mcr.microsoft.com/mssql/server:2022-latest + platform: linux/amd64 + profiles: ["sqlsrv"] + environment: + ACCEPT_EULA: "Y" + MSSQL_SA_PASSWORD: Firefly-Integration-1 + ports: ["${FIREFLY_IT_SQLSRV_PORT:-1433}:1433"] + healthcheck: + test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P Firefly-Integration-1 -Q 'SELECT 1'"] + interval: 10s + timeout: 5s + retries: 30 + rabbitmq: + image: rabbitmq:3.13-alpine + ports: ["${FIREFLY_IT_RABBITMQ_PORT:-5672}:5672"] + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] + interval: 10s + timeout: 10s + retries: 20 + kafka: + image: apache/kafka:3.8.0 + environment: + KAFKA_NODE_ID: 1 + KAFKA_PROCESS_ROLES: broker,controller + KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://127.0.0.1:${FIREFLY_IT_KAFKA_PORT:-9092} + KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT + KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9093 + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 + KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0 + ports: ["${FIREFLY_IT_KAFKA_PORT:-9092}:9092"] + healthcheck: + test: ["CMD-SHELL", "/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server 127.0.0.1:9092"] + interval: 10s + timeout: 10s + retries: 20 + redis: + image: redis:7-alpine + ports: ["${FIREFLY_IT_REDIS_PORT:-6379}:6379"] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 20 diff --git a/docs/README.md b/docs/README.md index 48a43e2d..e495c1ac 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,7 +28,7 @@ ## Module Guides Every module guide lives under [`modules/`](modules/), in the ten groups `mkdocs.yml`'s navigation and the -site's own [Modules](modules.md) landing page use. All 33 are listed here, and neither the list nor the +site's own [Modules](modules.md) landing page use. All 35 are listed here, and neither the list nor the number is maintained on trust: `tests/ModuleDocumentationTest.php` fails the build if a guide is missing from the navigation, from the table below, from the root `README.md`'s table, from `docs/index.md` or from the landing page, and `tests/SiteNavigationTest.php` reads that number off `docs/modules/*.md` itself, so a @@ -59,6 +59,7 @@ thirty-fourth guide turns this page red rather than quietly making it wrong. |-------|-------------| | [Resilience](modules/resilience.md) | `firefly/resilience` — Retry, CircuitBreaker, RateLimiter, Bulkhead, TimeLimiter, Fallback | | [Scheduling](modules/scheduling.md) | `firefly/scheduling` — `#[Scheduled]` on Laravel's own scheduler, with distributed-lock guard | +| [Scheduling Database Locks](modules/scheduling-database.md) | `firefly/scheduling-database` — database lease and MySQL named-lock `DistributedLock` providers | ### Data & Domain @@ -75,6 +76,7 @@ thirty-fourth guide turns this page red rather than quietly making it wrong. |-------|-------------| | [Event-Driven Architecture](modules/eda.md) | `firefly/eda` — broker-backed `EventPublisher`, envelopes, retry/DLQ, in-memory + queue adapters | | [EDA Brokers](modules/eda-brokers.md) | Real broker adapters behind the `EventPublisher` port — RabbitMQ, Postgres LISTEN/NOTIFY, Kafka | +| [EDA Database Outbox](modules/eda-database.md) | `firefly/eda-database` — the portable same-transaction outbox, claimed by leases on every database | | [Messaging](modules/messaging.md) | `firefly/messaging` — the lower-level raw-bytes `MessageBrokerPort`, `#[MessageListener]` | ### CQRS @@ -123,8 +125,9 @@ thirty-fourth guide turns this page red rather than quietly making it wrong. | Document | Description | |----------|-------------| -| [Modules](modules.md) | The grouped index of all 33 module guides, and how a package wires itself | -| [CLI Reference](cli.md) | Every `firefly:*` command and `make:firefly-*` generator, including the four contributed by capability packages | +| [Modules](modules.md) | The grouped index of all 35 module guides, and how a package wires itself | +| [CLI Reference](cli.md) | Every `firefly:*` command and `make:firefly-*` generator, including the seven contributed by capability packages | +| [Database Support Matrix](database-support.md) | What each database-backed capability does on PostgreSQL, MySQL, MariaDB, SQL Server and SQLite, and which CI lane proves it | | [Laravel Comparison](laravel-comparison.md) | Side-by-side concept mapping for developers coming from plain Laravel | | [Versioning](versioning.md) | CalVer (`YY.MM.Patch`), no `version` field, how Packagist derives releases from tags | | [Contributing](contributing.md) | Monorepo layout, local setup, the gate, the documentation guard, conventions | diff --git a/docs/architecture.md b/docs/architecture.md index 385e9408..62a0132c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -89,9 +89,10 @@ Data: Read those two rules together and the direction is unambiguous: `Data` may see `Domain`, and `Domain` may see nothing but `Kernel`. There is no entry anywhere in the ruleset that lets an aggregate reach for a repository adapter, an Eloquent model or an HTTP request. Read the ruleset the other way and the leaves fall -out too: `Admin`, `OpenApi`, `Cli`, `Testing`, `Installer` and the three broker adapters (`EdaRabbitmq`, -`EdaPostgres`, `EdaKafka`) appear in no other layer's list at all, so nothing in the framework depends on -them and each can be added or dropped without touching the rest. Two layers have `~` for a rule — `Kernel` +out too: `Admin`, `OpenApi`, `Cli`, `Testing`, `Installer`, the three broker adapters (`EdaRabbitmq`, +`EdaPostgres`, `EdaKafka`) and the two database adapters (`EdaDatabase`, `SchedulingDatabase`) appear in no +other layer's list at all — except `EdaDatabase`, which `EdaPostgres` builds on — so nothing else in the +framework depends on them and each can be added or dropped without touching the rest. Two layers have `~` for a rule — `Kernel` and `Installer` — which is Deptrac for "may depend on no layer whatsoever": the first is what makes the kernel's exception taxonomy safe for anyone to reuse, and the second is what keeps a global `firefly new` install from dragging the runtime family onto a developer's machine. diff --git a/docs/assets/README.md b/docs/assets/README.md index 15dd98e3..a1ce50a9 100644 --- a/docs/assets/README.md +++ b/docs/assets/README.md @@ -59,7 +59,7 @@ noted in its own `` element and below: | `boot-pipeline.svg` | `FireflyServiceProvider::register()` buffering `BootPass`es into `PendingBootPasses`, drained at `booting()`/`booted()`, phases run in kernel-decided order | `packages/context/src/Boot/{FireflyServiceProvider.php,PendingBootPasses.php,BootPhase.php}` | | `di-autoconfig.svg` | PSR-4 scanner → compiled manifest (`var_export`, zero-reflection load) → `ConditionPassTwoPass` (incremental `#[ConditionalOnMissingBean]`/`#[ConditionalOnProperty]` evaluation) → bean registry → eager singletons | `packages/container/src/Scanner/*`, `packages/context/src/{Scanner,Pass}/*`, `packages/autoconfigure/src/*` | | `request-lifecycle.svg` | Ordered `WebFilter` chain → `ControllerDispatcher` → `ControllerSecurityGuard`/`#[PreAuthorize]` → `#[RestController]` → RFC-7807 rendering on a thrown exception | `packages/web/src/{Filter,Dispatch,Route}/*`, `packages/security/src/Web/MethodSecurityControllerGuard.php` | -| `outbox-flow.svg` | The genuine same-transaction outbox: `OutboxPreCommitHook` INSERTs the `firefly_eda_outbox` row on the aggregate's own connection *inside* its open transaction, atomic commit, then a separate terminal `PostgresEventConsumer` claims `PENDING` rows with `FOR UPDATE SKIP LOCKED` and marks them `PUBLISHED` | `packages/data/src/{Transaction/TransactionTemplate.php,Domain/DomainEventDispatcher.php}`, `packages/eda-postgres/src/{Outbox/OutboxPreCommitHook.php,PostgresEventPublisher.php,PostgresEventConsumer.php}` | +| `outbox-flow.svg` | The genuine same-transaction outbox: `OutboxPreCommitHook` INSERTs the `firefly_eda_outbox` row on the aggregate's own connection *inside* its open transaction, atomic commit, then separate terminal `PostgresEventConsumer` workers lease `PENDING` rows (`SKIP LOCKED` on pgsql) and mark them `PUBLISHED`. Under `provider=database` the same flow runs without the NOTIFY. | `packages/data/src/{Transaction/TransactionTemplate.php,Domain/DomainEventDispatcher.php}`, `packages/eda-database/src/{Outbox/OutboxPreCommitHook.php,DatabaseEventConsumer.php,Outbox/Claim/}`, `packages/eda-postgres/src/{Outbox/OutboxPreCommitHook.php,PostgresEventPublisher.php,PostgresEventConsumer.php}` | | `cqrs-eda-bridge.svg` | The `CommandBus`/`QueryBus` pipelines plus the domain → integration-event bridge: a guarded wildcard listener on the in-process dispatcher re-emits every committed `DomainEvent` through `EdaCommandEventPublisher` onto the `firefly/eda` `EventPublisher` port | `packages/cqrs/src/{Command/DefaultCommandBus.php,Query/DefaultQueryBus.php,Event/DomainEventBridge.php,Event/EdaCommandEventPublisher.php,Boot/DomainEventBridgeWiringPass.php}` | | `security-filter-chain.svg` | The ordered `WebFilter` chain pushed onto Laravel's global middleware by `FilterChainRegistrar::orderedFilters()`, every filter's real `#[Order]`, and the `DelegatingAuthenticationEntryPoint` decision between a login redirect, a Basic challenge and a 401 | `packages/web/src/Filter/{FilterChainRegistrar.php,RequestContextFilter.php,CorrelationIdFilter.php}`, `packages/observability/src/Web/{TracingFilter.php,HttpExchangeFilter.php,MetricsFilter.php}`, `packages/security/src/{Web,Session,OAuth2}/*`, `packages/security-oauth2-client/src/Web/*`, `packages/security-oauth2-server/src/Web/OAuth2AuthorizationServerFilter.php` | | `oauth2-authorization-code.svg` | The authorization-code + PKCE round trip across browser, relying party and authorization server: the `HttpSecurityFilter` denial that reaches `LoginUrlAuthenticationEntryPoint` and sends the browser to `/login` (never straight to a provider), the `/oauth2/authorization/{id}` start, the 302 the relying party answers and the browser follows out to `/oauth2/authorize` (a front-channel leg, drawn through the browser lane exactly as the return leg is), the single-use `state`, the `nonce`, the S256 `code_challenge`, the one `OAuth2AuthorizationServerFilter` that matches every server endpoint, the server's own form login and its own `ConsentPage`, the single-use code, the back-channel `POST /oauth2/token` and the JWKS the id token's signature is checked against | `packages/security-oauth2-client/src/{OAuth2ClientSettings.php,Web/OAuth2AuthorizationRequestRedirectFilter.php,Web/OAuth2AuthorizationRequest.php,Web/OAuth2LoginAuthenticationFilter.php,Web/Login/OAuth2LoginPageLinks.php,Oidc/OidcIdTokenValidator.php}`, `packages/security-oauth2-server/src/{Settings/AuthorizationServerSettings.php,Web/OAuth2AuthorizationServerFilter.php,Web/AuthorizationEndpoint.php,Web/Consent/ConsentPage.php,Web/Grant/AuthorizationCodeGrant.php,Web/TokenEndpoint.php,Web/JwkSetEndpoint.php,Pkce/ProofKey.php}`, `packages/security/src/Web/{HttpSecurityFilter.php,EntryPoint/LoginUrlAuthenticationEntryPoint.php,Login/FormLoginFilter.php,Settings/FormLoginSettings.php}`, `packages/cli/src/Command/OAuth2KeysCommand.php` | diff --git a/docs/assets/diagrams/cqrs-eda-bridge.svg b/docs/assets/diagrams/cqrs-eda-bridge.svg index 1ff7087e..60c1bc9a 100644 --- a/docs/assets/diagrams/cqrs-eda-bridge.svg +++ b/docs/assets/diagrams/cqrs-eda-bridge.svg @@ -70,27 +70,31 @@ - - memory + + memory - - queue + + queue - - rabbitmq + + rabbitmq - - postgres - (same-tx outbox) + + database + (same-tx outbox) - - kafka + + postgres + (same-tx outbox) + + + kafka Fires for ANY committed aggregate — not only CommandBus writes. The bridge listens on the in-process dispatcher, not on a specific command's aggregate. - Non-atomic here: the DB commit and the broker publish are two steps with a gap — true same-tx atomicity is the postgres outbox adapter (see outbox-flow.svg). + Non-atomic here: the DB commit and the broker publish are two steps with a gap — true same-tx atomicity is the database outbox (provider=database or postgres; see outbox-flow.svg). diff --git a/docs/assets/diagrams/outbox-flow.svg b/docs/assets/diagrams/outbox-flow.svg index 9bbeee8b..34b2372c 100644 --- a/docs/assets/diagrams/outbox-flow.svg +++ b/docs/assets/diagrams/outbox-flow.svg @@ -1,6 +1,6 @@ The genuine same-transaction outbox - Aggregate transaction writes the outbox row in-tx; a separate terminal consumer claims PENDING rows with FOR UPDATE SKIP LOCKED. + Aggregate transaction writes the outbox row in-tx; separate consumers lease PENDING rows (SKIP LOCKED on pgsql) and deliver them. @@ -11,7 +11,7 @@ The genuine same-transaction outbox packages/data/src/{Transaction/TransactionTemplate.php,Domain/DomainEventDispatcher.php} - packages/eda-postgres/src/{Outbox/OutboxPreCommitHook.php,PostgresEventPublisher.php,PostgresEventConsumer.php} + packages/eda-database/src/{Outbox/OutboxPreCommitHook.php,DatabaseEventConsumer.php} · packages/eda-postgres/src/{PostgresEventPublisher.php,PostgresEventConsumer.php} @@ -81,14 +81,14 @@ - poll(timeoutMs): NotificationWaiter waits (best-effort) for the queued NOTIFY — - delivered exactly when the row's commit lands + poll(timeoutMs): claims first; only when nothing is claimable does NotificationWaiter + wait (best-effort) for the NOTIFY delivered when the row's commit lands, then claim again - claim: SELECT * FROM firefly_eda_outbox WHERE status='PENDING' - ORDER BY id FOR UPDATE SKIP LOCKED (pgsql) + claim (a lease): firefly_eda_outbox rows WHERE status='PENDING' AND lease free + ORDER BY id · SKIP LOCKED / READPAST / conditional UPDATE → claimed_by, claimed_until @@ -101,16 +101,16 @@ success → ack(): UPDATE … SET status='PUBLISHED' - WHERE id=? AND status='PENDING' (idempotent) + WHERE id=? AND status='PENDING' AND claimed_by=me (idempotent) throw → nack(): attempts+1 → back to PENDING (retry) - or, past max_attempts, status='FAILED' + or, past max-attempts, status='FAILED' No EventPublisher::publish() call anywhere in this consumer path — it reads/updates the outbox table directly: no re-INSERT, no self-reference. - A single consumer worker gives exactly-once in-process delivery; multiple workers degrade to at-least-once (handlers should be idempotent). + Each row is leased to one consumer while it is delivered: any number of workers; at-least-once only across a crash. diff --git a/docs/book.md b/docs/book.md index e289f3b5..3dd211ff 100644 --- a/docs/book.md +++ b/docs/book.md @@ -27,7 +27,7 @@ composer global require fireflyframework/larafly firefly new my-app ``` -All 30 components are included in that package. An existing application starts with +All 32 components are included in that package. An existing application starts with `composer require fireflyframework/larafly`; compatible legacy `firefly/*` requirements are then satisfied by the framework's `replace` declarations. See [Installation](installation.md) for details. diff --git a/docs/cli.md b/docs/cli.md index f208be69..f9f54fe2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -8,10 +8,11 @@ framework stereotype (`make:firefly-*`), and thinly delegates to Laravel's own ` commands (`firefly:serve`/`firefly:schedule`/`firefly:db`). Its own Deptrac `Cli` layer — depends on the rest of the framework, depended on by nothing. -Five more `firefly:*` commands ship with the capability they belong to rather than with the console: -`firefly:management:serve` (`firefly/actuator`), `firefly:openapi` (`firefly/openapi`), -`firefly:eda:consume` (`firefly/eda`), `firefly:outbox:relay` (`firefly/eda-postgres`) and `firefly:flags` -(`firefly/feature-flags`). They are all listed at the end of this page. +Seven more `firefly:*` commands ship with the capability they belong to rather than with the console: +`firefly:management:serve` (`firefly/actuator`), `firefly:openapi` (`firefly/openapi`), `firefly:eda:consume` and +`firefly:eda:dead-letters:replay` (`firefly/eda`), `firefly:outbox:relay` (`firefly/eda-database`), +`firefly:messaging:dead-letters:replay` (`firefly/messaging`) and `firefly:flags` (`firefly/feature-flags`). They +are all listed at the end of this page. ## `firefly:cache` @@ -328,5 +329,7 @@ command is part of that capability rather than of the console. | `firefly:openapi` | `firefly/openapi` | Writes the generated OpenAPI 3.1 document to `--output=` (parent directories are created, and a summary line is printed) or **raw** to stdout. Stdout is written with Symfony's `OUTPUT_RAW` so the bytes are exactly the document's — `php artisan firefly:openapi \| ` is the intended use — which is also why the confirmation line prints only in `--output` mode. See [OpenAPI](modules/openapi.md#php-artisan-fireflyopenapi). | | `firefly:management:serve` | `firefly/actuator` | Runs a second dev listener for the actuator on the management port. `--host=`, `--port=`. See [`firefly:management:serve`](#fireflymanagementserve) above. | | `firefly:eda:consume` | `firefly/eda` | Runs the configured broker `EventConsumer`, dispatching to `#[EventListener]` handlers until stopped. `--destination=*` (repeatable; overrides `firefly.eda.destinations`), `--max-messages=`, `--time-limit=`, `--sleep=0` (idle ms between empty polls), `--poll-timeout=5000` (block ms per poll). The bound destinations are echoed at startup, so a worker subscribed to nothing is visible on line one. Errors out when no broker `EventConsumer` is bound — the `memory` and `queue` providers have no consumer loop, and `queue` uses `php artisan queue:work`. See [EDA](modules/eda.md). | -| `firefly:outbox:relay` | `firefly/eda-postgres` | **Optional.** Forwards committed `firefly_eda_outbox` rows to the downstream broker named by `firefly.eda.postgres.relay.downstream_provider` (claim → publish → mark `PUBLISHED`/`FAILED`). `--max-messages=`, `--time-limit=`, `--sleep=1` (seconds between empty batches), `--batch-size=50`. In-process delivery is `firefly:eda:consume`'s job, not this command's, so an app that only needs `#[EventListener]` handlers never runs it; an unset or misconfigured `downstream_provider` fails loudly before a single row is claimed. See [EDA Brokers](modules/eda-brokers.md). | +| `firefly:eda:dead-letters:replay` | `firefly/eda` | Republishes dead-lettered envelopes (`firefly.eda.dead-letter.store=database`) through the bound `EventPublisher`, oldest first, to their original destination, removing each one that republished. `--id=*`, `--destination=`, `--event-type=`, `--limit=100`, `--dry-run`, `--keep`; exit 1 when any entry failed. Registered only under that `database` store. | +| `firefly:messaging:dead-letters:replay` | `firefly/messaging` | Republishes dead-lettered messages (`firefly.messaging.dead-letter.store=database`) through the bound `MessageBrokerPort` to their original topic. `--id=*`, `--topic=`, `--limit=100`, `--dry-run`, `--keep`; exit 1 when any entry failed. Registered only under that `database` store. | +| `firefly:outbox:relay` | `firefly/eda-database` | **Optional.** Forwards committed `firefly_eda_outbox` rows to the downstream broker named by `firefly.eda.database.relay.downstream-provider` (alias `firefly.eda.postgres.relay.downstream_provider`) (claim → publish → mark `PUBLISHED`/`FAILED`); any number of relay processes may run — each batch is leased. `--max-messages=`, `--time-limit=`, `--sleep=1` (seconds between empty batches), `--batch-size=50`. In-process delivery is `firefly:eda:consume`'s job, not this command's, so an app that only needs `#[EventListener]` handlers never runs it; an unset or misconfigured `downstream-provider` fails loudly before a single row is claimed. See [EDA Database Outbox](modules/eda-database.md). | | `firefly:flags` | `firefly/feature-flags` | Runs the `flags` management operations in-process: `list`, `show {key}`, `evaluate {key}` (`--context=`, `--targeting-key=`), and the writes `enable`, `disable`, `default-variant {key} {variant}`, `put {key} --file=`, `delete {key}` (each with `--expected-version=`). `--json` prints the management JSON, including `{error,message}` on a refusal. Writes need `firefly.feature-flags.management.writes` and a flag store; the actor is the principal, else `cli:`. An accepted write with pending local refresh exits successfully and asks the operator to check visibility again. | diff --git a/docs/contributing.md b/docs/contributing.md index 351a12ea..cf230ec6 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -48,6 +48,10 @@ bash scripts/check-no-sensitive-tracked.sh # the pre-push guard, run directly never needs Node. The documentation tests *are* in it — they are ordinary Pest files under `tests/` — so a stale code listing or an unlinked module guide fails `composer check` like any other regression. +`composer test:integration` runs the integration group against real servers; start them with +`docker compose -f docker-compose.integration.yml up -d`, export the DSNs listed in that file's header, and see +[Integration Testing](modules/integration-testing.md#ci-lanes). + ## Browser tests `tests/Browser/` drives the shipped skeleton app in a real Chromium through `pestphp/pest-plugin-browser` diff --git a/docs/database-support.md b/docs/database-support.md new file mode 100644 index 00000000..c218cca9 --- /dev/null +++ b/docs/database-support.md @@ -0,0 +1,27 @@ +# Database Support Matrix + +Every database-backed capability LaraFly ships, and what it does on each database Laravel speaks. "Proven" means +the `integration` CI lane for that server runs the capability's integration tests on every pull request +(`composer test:integration`, see [Integration Testing](modules/integration-testing.md)); SQLite runs in every lane +on a temporary file. + +| Capability | PostgreSQL 16 | MySQL 8.4 | MariaDB 11 | SQL Server 2022 | SQLite | +|---|---|---|---|---|---| +| Same-transaction outbox (`firefly.eda.provider=database`) | ✓ SKIP LOCKED claim | ✓ SKIP LOCKED claim (8.0.1+), READ COMMITTED | ✓ SKIP LOCKED claim (10.6+), READ COMMITTED | ✓ READPAST claim | ✓ optimistic lease | +| Concurrent consumers and relays on one outbox | ✓ proven (two processes) | ✓ proven | ✓ proven | ✓ proven (CI lane) | ✓ proven (WAL) | +| `pg_notify` / `LISTEN` wake-up (`provider=postgres`) | ✓ | — | — | — | — | +| Durable eda dead letters (`firefly.eda.dead-letter.store=database`) | ✓ | ✓ | ✓ | ✓ | ✓ | +| Durable messaging dead letters (`firefly.messaging.dead-letter.store=database`) | ✓ | ✓ | ✓ | ✓ | ✓ | +| `lock.provider=database` (lease table, TTL honoured) | ✓ | ✓ | ✓ | ✓ | ✓ | +| `lock.provider=mysql` (`GET_LOCK`, session-scoped) | — | ✓ | ✓ | — | — | +| `lock.provider=postgres` (advisory lock, session-scoped) | ✓ | — | — | — | — | +| `DataAccessException` translation (duplicate, integrity, grammar, lock wait) | ✓ | ✓ | ✓ | ✓ | ✓ | +| Deadlock translation | ✓ | ✓ | ✓ | ✓ | — (no row locks) | +| `#[Transactional(timeout:)]` at the server | `SET LOCAL statement_timeout` | `max_execution_time` (reads) + `innodb_lock_wait_timeout` | `max_statement_time` + `innodb_lock_wait_timeout` | `SET LOCK_TIMEOUT` (lock waits) | busy timeout | +| `#[Lock(PESSIMISTIC_WRITE / READ)]` | ✓ `FOR UPDATE` / `FOR SHARE` | ✓ `FOR UPDATE` / `LOCK IN SHARE MODE` | ✓ | ✓ `UPDLOCK` / `HOLDLOCK` hints | — (compiles to a plain read) | +| Optimistic locking (`HasOptimisticLock`) | ✓ | ✓ | ✓ | ✓ | ✓ | + +How the outbox claim is chosen, and why every strategy is safe with any number of workers, is explained in +[EDA Database Outbox](modules/eda-database.md#claim-strategies). Servers older than the versions above fall back to +the portable optimistic lease automatically — the choice is made from the server's reported version, never from +the driver's name. diff --git a/docs/index.md b/docs/index.md index 7a6ed7ba..58d81f66 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,7 +8,7 @@ CQRS, event-driven architecture, first-party security including both halves of O directly onto Laravel's own runtime. Nothing is forked or wrapped: a LaraFly app is, in every respect a Laravel developer would recognize, still a Laravel app — it just boots like a Spring Boot one. -The whole framework ships as one Composer library, `fireflyframework/larafly`, containing all 30 components, +The whole framework ships as one Composer library, `fireflyframework/larafly`, containing all 32 components, wired together by a single zero-reflection boot pipeline: a component scan compiles to a cached manifest once, and every request after that runs against plain PHP arrays — no runtime reflection on the hot path. @@ -72,8 +72,8 @@ See [Installation](installation.md) for requirements and manual setup, and proxy per bean runs the whole advice chain, security *before* transaction (`Advice` order 100 against 1000), so a refusal is thrown before a transaction is ever opened. A `CommandBus`/`QueryBus` mediator dispatches commands and queries, with domain events bridged onto the event-transport bus after commit. -- **Event-driven, with real brokers** — an in-memory default plus RabbitMQ, Postgres LISTEN/NOTIFY, and Kafka - adapters behind one `EventPublisher` port. +- **Event-driven, with real brokers** — an in-memory default plus a same-transaction outbox on every SQL database + (with LISTEN/NOTIFY on PostgreSQL), RabbitMQ and Kafka adapters behind one `EventPublisher` port. - **Secure by default** — a session-persisted `SecurityContext`, form login on the framework's own page, HTTP Basic, remember-me, logout, deny-by-default [`HttpSecurity`](modules/security.md) URL rules, and method security (`#[PreAuthorize]`, `#[PostAuthorize]`, `#[PreFilter]`, `#[PostFilter]`) enforced on **any** @@ -103,7 +103,7 @@ See [Installation](installation.md) for requirements and manual setup, and ## The modules -Every capability above ships in the root library; configuration selects optional features. The [module index](modules.md) lays all 33 +Every capability above ships in the root library; configuration selects optional features. The [module index](modules.md) lays all 35 guides out by concern, with a line on each saying what it is for, and the same grouping is the site's **Modules** tab. @@ -111,9 +111,9 @@ guides out by concern, with a line on each saying what it is for, and the same g |---|---| | **Foundation** | [Error Handling](modules/error-handling.md) · [Dependency Injection](modules/dependency-injection.md) · [Configuration](modules/configuration.md) · [Application Context](modules/context.md) · [Auto-Configuration](modules/starters.md) · [Validation](modules/validation.md) | | **Web & API** | [Web Layer](modules/web.md) · [Web Filters](modules/web-filters.md) · [OpenAPI](modules/openapi.md) | -| **Resilience & Scheduling** | [Resilience](modules/resilience.md) · [Scheduling](modules/scheduling.md) | +| **Resilience & Scheduling** | [Resilience](modules/resilience.md) · [Scheduling](modules/scheduling.md) · [Scheduling Database Locks](modules/scheduling-database.md) | | **Data & Domain** | [Domain (DDD)](modules/domain.md) · [Data & Repositories](modules/data.md) · [Relational Data](modules/data-relational.md) · [Transactions](modules/transactional.md) | -| **Eventing & Messaging** | [EDA](modules/eda.md) · [EDA Brokers](modules/eda-brokers.md) · [Messaging](modules/messaging.md) | +| **Eventing & Messaging** | [EDA](modules/eda.md) · [EDA Brokers](modules/eda-brokers.md) · [EDA Database Outbox](modules/eda-database.md) · [Messaging](modules/messaging.md) | | **CQRS** | [Command/Query](modules/cqrs.md) | | **Security** | [Security](modules/security.md) · [OAuth2 Client](modules/security-oauth2-client.md) · [OAuth2 Authorization Server](modules/security-oauth2-server.md) | | **Operations** | [Actuator](modules/actuator.md) · [Observability](modules/observability.md) · [Tracing](modules/tracing.md) · [Logging](modules/logging.md) · [Admin Dashboard](modules/admin.md) · [Bean Graph](modules/bean-graph.md) · [Data Browser](modules/data-browser.md) · [Feature Flags](modules/feature-flags.md) | diff --git a/docs/installation.md b/docs/installation.md index a2cbba99..dba27024 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -31,8 +31,9 @@ off `NewCommand::configure()`: Every component's code is included in `fireflyframework/larafly`. `--with` records an explicit component requirement in the generated manifest; it does not fetch a separate framework package. Configuration -selects the active transport. `eda-postgres` needs `ext-pdo_pgsql`, `eda-kafka` needs `ext-rdkafka`, -`eda-rabbitmq` uses the included AMQP client, and `scheduling-postgres` needs a PostgreSQL connection. +selects the active transport. `eda-database` and `scheduling-database` work on any database connection, +`eda-postgres` needs `ext-pdo_pgsql`, `eda-kafka` needs `ext-rdkafka`, `eda-rabbitmq` uses the included AMQP +client, and `scheduling-postgres` needs a PostgreSQL connection. `--with=testing` adds `orchestra/testbench` and `illuminate/testing` to **`require-dev`**, alongside the compatible `firefly/testing` requirement. See [Installer](modules/installer.md). diff --git a/docs/modules.md b/docs/modules.md index 69241792..1ab6c164 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -1,12 +1,12 @@ # Modules -LaraFly publishes one `fireflyframework/larafly` library containing the **30 component directories** under -`packages/*`, each with its own tests or metadata. The **33 guides** below explain their public surfaces. +LaraFly publishes one `fireflyframework/larafly` library containing the **32 component directories** under +`packages/*`, each with its own tests or metadata. The **35 guides** below explain their public surfaces. Several components carry more than one guide: `firefly/data` answers for *Data & Repositories*, *Relational Data* and *Transactions*. Laravel discovers the root library's providers, and configuration selects optional features. A bean you declare wins over the framework default. Two idioms deliver that outcome, and -which one you are looking at matters the moment you go reading the source. **18 of the 30 packages** carry a +which one you are looking at matters the moment you go reading the source. **20 of the 32 packages** carry a `#[Configuration]` class whose `#[Bean]` methods sit behind `#[ConditionalOnMissingBean]`, so a bean of yours makes the framework's back off silently — `packages/data/src/DataAutoConfiguration.php` is the model. The **other 12** have no such class to go looking for; `firefly/web`, the one nearly every reader installs, guards @@ -30,8 +30,8 @@ each binding in `WebServiceProvider::register()` with a `bound()` check instead,
Resilience & Scheduling -[Resilience](modules/resilience.md) · [Scheduling](modules/scheduling.md) -Retry, circuit breaker, bulkhead, time limiter, rate limiter and fallback, each a `call(callable)` object built from `firefly.resilience.*`; `#[Scheduled]` methods held by a cache or Postgres-advisory distributed lock. +[Resilience](modules/resilience.md) · [Scheduling](modules/scheduling.md) · [Scheduling Database Locks](modules/scheduling-database.md) +Retry, circuit breaker, bulkhead, time limiter, rate limiter and fallback, each a `call(callable)` object built from `firefly.resilience.*`; `#[Scheduled]` methods held by a cache, database-lease, MySQL named or Postgres-advisory distributed lock.
@@ -43,8 +43,8 @@ each binding in `WebServiceProvider::register()` with a `bound()` check instead,
Eventing & Messaging -[EDA](modules/eda.md) · [EDA Brokers](modules/eda-brokers.md) · [Messaging](modules/messaging.md) -One `EventPublisher` port with in-memory, queue, RabbitMQ, Postgres and Kafka adapters, a genuine same-transaction outbox, and the lower-level raw-bytes broker layer beneath it. +[EDA](modules/eda.md) · [EDA Brokers](modules/eda-brokers.md) · [EDA Database Outbox](modules/eda-database.md) · [Messaging](modules/messaging.md) +One `EventPublisher` port with in-memory, queue, RabbitMQ, database (any SQL server), Postgres and Kafka adapters, a genuine same-transaction outbox safe with concurrent consumers on every database, and the lower-level raw-bytes broker layer beneath it.
diff --git a/docs/modules/cqrs.md b/docs/modules/cqrs.md index e93da570..cdf09cc5 100644 --- a/docs/modules/cqrs.md +++ b/docs/modules/cqrs.md @@ -116,8 +116,9 @@ work raises no `DomainEvent` at all (Laravel discards `afterCommit` callbacks on ever published for it. **Non-atomic caveat:** the DB commit and the broker publish are two steps with a gap — an honest -at-least-once-ish, non-atomic guarantee. True exactly-once durability is the SP-4 transactional outbox -(`firefly/eda-postgres`), which hangs off the same `DB::afterCommit` hook + a Postgres advisory lock. +at-least-once-ish, non-atomic guarantee. Atomic durability is the same-transaction outbox +(`firefly.eda.provider=database`, or `postgres`), which writes the event inside the aggregate's transaction +through `firefly/data`'s `PreCommitEventHook`. When no firefly/eda `EventPublisher` is bound, the bridge falls back to `NoOpEventPublisher` (drops silently), so cqrs installed without eda still boots and simply emits no integration events. @@ -147,8 +148,7 @@ edge): the bridge listens for `DomainEvent`s on the in-process `Context` dispatc Read-model / projection scaffolding (→ `firefly/eventsourcing`), real authorization (→ M11), real query cache (→ firefly/cache), CQRS metrics + health (→ M12), attribute-driven command validation, the fluent -builder / distributed-tracing ergonomics, and exactly-once outbox durability (→ SP-4) are deferred and -documented honestly. The `HandlerManifest` needs no hand-wiring: like every other +builder / distributed-tracing ergonomics are deferred and documented honestly. The `HandlerManifest` needs no hand-wiring: like every other compiled manifest it resolves to the `firefly:cache` artifact if present, otherwise an in-process scan of `firefly.scan.paths`, otherwise empty — so an uncached app registers the same handlers a cached one does. A handler outside `firefly.scan.paths` still never registers. Under Octane, `CorrelationContext` is diff --git a/docs/modules/eda-brokers.md b/docs/modules/eda-brokers.md index 418266b6..4de6b8b5 100644 --- a/docs/modules/eda-brokers.md +++ b/docs/modules/eda-brokers.md @@ -2,8 +2,9 @@ `firefly/eda-rabbitmq`, `firefly/eda-postgres`, and `firefly/eda-kafka` are real message-broker adapters behind the `firefly/eda` (M9) `EventPublisher` port — the "real brokers" seam M9 deliberately -left for later (see [Event-Driven Architecture § Known-latent](eda.md#known-latent)). Each is its own -opt-in package, gated behind `firefly.eda.provider`, and each ships its own `HealthIndicator`. Installing +left for later. Each is its own opt-in package, gated behind `firefly.eda.provider`, and each ships its own +`HealthIndicator`. The Postgres adapter is the portable [database outbox](eda-database.md) plus Postgres's two +optimizations; on any other database select `firefly.eda.provider=database` instead. Installing any of them without selecting it as the active provider is fully inert: no connection is opened, no extension is required, and `/actuator/health` is unaffected. @@ -18,7 +19,7 @@ messages back off the wire — something M9's `start()`/`stop()` never needed. | Package | Client library | Destination shape | DLQ | Health indicator name | |---|---|---|---|---| | `firefly/eda-rabbitmq` | `php-amqplib/php-amqplib` (AMQP 0-9-1) | `"exchange/routingKey"` (or a bare routing key against the default exchange) | Broker-native DLX (dead-letter exchange) | `rabbitmq` | -| `firefly/eda-postgres` | `ext-pdo_pgsql` (via Laravel's `ConnectionInterface`) | a destination string stored on the outbox row | Outbox row `status='FAILED'` | `postgres` | +| `firefly/eda-postgres` | `ext-pdo_pgsql`; builds on `firefly/eda-database` | a destination string stored on the outbox row | Outbox row `status='FAILED'` | `postgres` | | `firefly/eda-kafka` | `ext-rdkafka` (**OPTIONAL**) | a topic name | Dead-letter topic `.DLT`, with `x-dlt-*` provenance headers | `kafka` | All three implement the same `EventPublisher::publish(string $destination, string $eventType, array @@ -29,10 +30,11 @@ bool)/stop()` shape. ## The `firefly.eda.provider` switch -`firefly.eda.provider` now has five values: `memory` (default) and `queue` bind the M9 in-process/queue -adapters (`EdaAutoConfiguration`, `#[Order(1000)]`); `rabbitmq`, `postgres`, and `kafka` each bind their own -`EventPublisher` + `EventConsumer` pair from `#[Order(900)]` autoconfigurations (`RabbitMqAutoConfiguration`, -`PostgresOutboxAutoConfiguration`, `KafkaAutoConfiguration`) — running *before* `EdaAutoConfiguration`'s +`firefly.eda.provider` now has six values: `memory` (default) and `queue` bind the M9 in-process/queue +adapters (`EdaAutoConfiguration`, `#[Order(1000)]`); `database`, `rabbitmq`, `postgres`, and `kafka` each bind +their own `EventPublisher` + `EventConsumer` pair from `#[Order(900)]` autoconfigurations +(`DatabaseOutboxAutoConfiguration`, `RabbitMqAutoConfiguration`, `PostgresOutboxAutoConfiguration`, +`KafkaAutoConfiguration`) — running *before* `EdaAutoConfiguration`'s `#[ConditionalOnMissingBean(EventPublisher::class)]` so it backs off correctly (the `firefly/scheduling-postgres` win-the-race precedent). Each broker's beans are additionally `#[ConditionalOnProperty(name: 'firefly.eda.provider', havingValue: '')]`-gated, so with any other @@ -56,12 +58,15 @@ provider selected the whole package resolves nothing and requires nothing. ### Postgres (`firefly/eda-postgres`) +Under `provider=postgres` the outbox reads [the database outbox's keys](eda-database.md#configuration); the +`firefly.eda.postgres.*` spellings below remain as aliases. + | Key | Default | Meaning | |---|---|---| -| `firefly.eda.postgres.connection` | the default DB connection | The named Laravel connection the outbox publisher/consumer/relay use — must be `pgsql` for `pg_notify`/`LISTEN` to activate (any other driver, e.g. `sqlite` in tests, silently skips the NOTIFY optimization and falls back to polling). | -| `firefly.eda.postgres.channel` | `firefly_eda_events` | The `LISTEN`/`NOTIFY` channel name and the outbox row's `channel` column value. | -| `firefly.eda.postgres.max_attempts` | `3` | How many `nack()`s (in-process consumer) or failed relay attempts an outbox row tolerates before it is marked `FAILED`. | -| `firefly.eda.postgres.relay.downstream_provider` | *(unset)* | **OPTIONAL.** Selects the relay's downstream: the shipped aliases `rabbitmq`\|`kafka`, the class-string of any `EventPublisher`, or a bound container id. When set, `firefly:outbox:relay` forwards `PENDING` rows there; when unset the command refuses to run with an error naming this key, and delivery is entirely the terminal in-process consumer's job. An app that binds its own publisher under the container id `firefly.eda.relay.downstream` may leave this key unset — that binding is checked first. Resolution is validated when the relay command runs (not at boot), so a misconfiguration fails before a single row is claimed instead of at the first publish. Whatever it resolves to, a `PostgresEventPublisher` is **refused**: it would re-insert `PENDING` rows into the same outbox. | +| `firefly.eda.postgres.connection` | the default DB connection | Alias of `firefly.eda.database.connection`. The connection must be `pgsql` for `pg_notify`/`LISTEN` to activate; on any other driver the NOTIFY optimization is skipped and the consumer polls. | +| `firefly.eda.postgres.channel` | `firefly_eda_events` | Alias of `firefly.eda.database.channel` — also the `LISTEN`/`NOTIFY` channel name. | +| `firefly.eda.postgres.max_attempts` | `3` | Alias of `firefly.eda.database.max-attempts`. | +| `firefly.eda.postgres.relay.downstream_provider` | *(unset)* | Alias of `firefly.eda.database.relay.downstream-provider` — see [the optional relay](#b-the-optional-relay-fireflyoutboxrelay). | ### Kafka (`firefly/eda-kafka`) @@ -87,7 +92,7 @@ trace the row carries instead of a fresh root. It does nothing unless `firefly.observability.tracing.enabled` and `firefly.observability.tracing.eda.enabled` are on (with them off the bound `EdaTracing` is the no-op). Set it to `false` to keep in-process spans while refusing to put trace identifiers on a wire someone else reads. The key is read in exactly one place — -`Firefly\Eda\Tracing\BrokerTracing` — by the three publisher beans and by `RelayDownstream`, because a gate +`Firefly\Eda\Tracing\BrokerTracing` — by the publisher beans (the database outbox's too) and by `RelayDownstream`, because a gate that lives only in a bean fails open wherever a publisher is built by container autowiring. The relay honours it whether you name a shipped adapter by its alias (`rabbitmq`) or by its own class-string: both spellings take the same configured path. A downstream of your OWN — a publisher class this package ships no adapter for, or one you @@ -106,7 +111,9 @@ M9 shipped only at-least-once delivery within a process/queue — publishing a d atomic with the aggregate's own database write. `firefly/eda-postgres` closes that gap with a **genuine same-transaction outbox**: the `firefly_eda_outbox` row is written *inside* the aggregate's own open transaction, on the aggregate's own connection, so it commits or rolls back atomically with the aggregate -— no dual-write, no "commit then hope the publish succeeds." +— no dual-write, no "commit then hope the publish succeeds." The outbox itself — schema, writer, pre-commit +hook, leased consumer and relay — lives in [`firefly/eda-database`](eda-database.md), which runs it on every +database under `provider=database`; this package adds the in-transaction `pg_notify` and the `LISTEN` wake-up. ![The genuine same-transaction outbox](../assets/diagrams/outbox-flow.svg) @@ -126,7 +133,7 @@ interface PreCommitEventHook `DomainEventDispatcher::afterCommit()` — which already runs *during* the pre-commit drain, before `DB::connection($connection)->afterCommit(...)` is even registered — now calls `$this->preCommitHook?->handle($event, $connection)` first. The hook is `null` by default: with no -broker package installed (or any provider other than `postgres`), `DomainEventDispatcher` behaves exactly +outbox package installed (or any provider other than `database` or `postgres`), `DomainEventDispatcher` behaves exactly as it did in M8 — publish deferred to `DB::afterCommit` only, zero behaviour change. `$connection` is threaded through unchanged from the dispatcher, so a `#[Transactional(connection: 'x')]` aggregate's outbox row lands on connection `x`, not some default connection. @@ -149,14 +156,16 @@ that transaction actually commits — so the consumer's `LISTEN` wakes with low moment the row becomes visible to other connections, and never wakes for a row that gets rolled back. Outbox columns (one source of truth, `OutboxSchema`): `id, destination, channel, event_type, payload, -headers, transaction_id, status, attempts, error_message, created_at, processed_at, failed_at`. `status` +headers, transaction_id, status, attempts, error_message, created_at, processed_at, failed_at, claimed_by, +claimed_until` — the last two are the claim's lease. `status` is one of `PENDING` (just written) / `PUBLISHED` (delivered) / `FAILED` (exhausted `max_attempts`). ### Double-publish avoidance With `provider=postgres`, both the in-tx `OutboxPreCommitHook` *and* the ordinary M10 after-commit bridge (`DomainEventBridge` → `CommandEventPublisher` → `EdaCommandEventPublisher`) could in principle write the -same event to the outbox twice. `PostgresOutboxAutoConfiguration` prevents this by binding, all at +same event to the outbox twice. `PostgresOutboxAutoConfiguration` (and `DatabaseOutboxAutoConfiguration` does the +same under `provider=database`) prevents this by binding, all at `#[Order(900)]` (ahead of `firefly/data`'s and `firefly/cqrs`'s `#[Order(1000)]` defaults): - its own `DomainEventDispatcher` carrying the `OutboxPreCommitHook` (wins `DataAutoConfiguration`'s @@ -179,35 +188,33 @@ state. Pick one (or neither, if you're only using RabbitMQ/Kafka directly and ne This is the default, always-available path: `firefly:eda:consume` drives `PostgresEventConsumer`, which: 1. `subscribe()` issues `LISTEN ` (pgsql only). -2. `poll($timeoutMs)` first waits (briefly, best-effort) on a `NOTIFY` via `NotificationWaiter` — guarded so - *any* failure on that wait (including a deprecation-to-exception handler tripping on the - soon-to-be-deprecated `pgsqlGetNotify()` fallback path) never crashes the poll loop — then, regardless of - whether a notification arrived, **claims** the oldest `status='PENDING'` row with - `SELECT ... FOR UPDATE SKIP LOCKED` (pgsql) or a plain `ORDER BY id` (sqlite, for tests). The poll-fallback - claim also picks up rows that were written while no consumer was listening. +2. `poll($timeoutMs)` first **claims** one row — a lease written by the strategy the server supports + (`SKIP LOCKED` on pgsql) — and only when nothing was claimable waits (briefly, best-effort, guarded against + any failure, including a deprecation-to-exception handler tripping on the soon-to-be-deprecated + `pgsqlGetNotify()` fallback path) on a `NOTIFY` via `NotificationWaiter`, then claims once more. The claim + also picks up rows that were written while no consumer was listening. 3. The claimed row is handed to `ConsumerLoop`, which delivers it to the `SubscriberRegistry` — driving every matching `#[EventListener]` handler, exactly as the in-memory/queue adapters do. -4. On success, `ack()` runs a guarded `UPDATE ... SET status='PUBLISHED' WHERE id=? AND status='PENDING'` — - the `WHERE status='PENDING'` guard makes the ack idempotent. On a handler throw, `nack()` increments - `attempts` and either resets the row to `PENDING` (retry) or — past `max_attempts` — marks it `FAILED`. +4. On success, `ack()` runs a guarded `UPDATE ... SET status='PUBLISHED' WHERE id=? AND status='PENDING' AND + claimed_by=` — the guard makes the ack idempotent and settles only a row this consumer still + holds. On a handler throw, `nack()` increments `attempts` and either frees the row as `PENDING` (retry) or — + past `max-attempts` — marks it `FAILED`. There is **no `EventPublisher::publish()` call anywhere in this path** — `PostgresEventConsumer` reads and updates the outbox table directly, so there is no re-INSERT and no risk of self-reference. -This is a **durable `PENDING`→`PUBLISHED` status window**, not an in-memory high-water mark: a restarted -consumer resumes at the oldest still-`PENDING` row and never replays a `PUBLISHED` row. A **single** -consumer worker gives exactly-once in-process delivery. Running **multiple** workers concurrently degrades -to at-least-once — `FOR UPDATE SKIP LOCKED` plus the guarded `ack()` UPDATE prevent two workers from both -claiming or both acking the same row, but a worker that crashes *after* running the handler but *before* -its `ack()` commits will have another worker (or its own restart) redeliver that row. **Write your -`#[EventListener]` handlers idempotently** if you run more than one consumer worker. A single worker -consuming in `id` order also gives effective FIFO per outbox (not per-partition/per-key ordering like -Kafka — just the natural insertion order of one table). +This is a **durable `PENDING`→`PUBLISHED` status window**: a restarted consumer resumes at the oldest +claimable row and never replays a `PUBLISHED` one. Any number of consumer workers may run at once — each row is +leased to one of them while it is delivered, and a worker that crashes mid-delivery leaves its row to the next +worker when the lease ends (`firefly.eda.database.lease-seconds`), so across a crash delivery is at-least-once: +write your `#[EventListener]` handlers idempotently. Workers share the table, so ordering is the table's +insertion order only per worker, not across them. ### (b) The optional relay — `firefly:outbox:relay` `firefly:outbox:relay` is a **distinct, optional** path that fronts a **different downstream broker**. Enable -it by setting `firefly.eda.postgres.relay.downstream_provider` — to a shipped alias (`rabbitmq`/`kafka`), to +it by setting `firefly.eda.database.relay.downstream-provider` (or its alias +`firefly.eda.postgres.relay.downstream_provider`) — to a shipped alias (`rabbitmq`/`kafka`), to an `EventPublisher` class-string, or to the id of a binding you supply — or by binding your own publisher under the container id `firefly.eda.relay.downstream` (checked first, so the key may then stay unset). A shipped adapter named by its OWN class-string is the same downstream as its alias and resolves identically, @@ -224,13 +231,12 @@ row is claimed. Boot-time validation was considered and rejected: a downstream b `boot()` may not exist yet when the check would run, so it would fail correctly-configured applications — and it would fail every web request and every unrelated artisan command over a relay-only concern. -`OutboxRelay::relayBatch()` claims a batch of `PENDING` rows (`FOR UPDATE SKIP LOCKED` on pgsql, inside a -short transaction so concurrent relay workers never double-claim; a plain per-row `WHERE id=? AND -status='PENDING'` guard on drivers without row locking), publishes each through the configured downstream -`EventPublisher`, and marks it `PUBLISHED` — or, on a publish failure, increments `attempts` and marks it -`FAILED` past `max_attempts`, exactly like the in-process consumer's `nack()`. +`OutboxRelay::relayBatch()` leases a batch of claimable rows with the same strategy as the consumer (several +relay workers never forward the same row), forwards each through the configured downstream `EventPublisher`, +and marks it `PUBLISHED` — or, on a forward failure, counts the attempt and marks it `FAILED` past +`max-attempts`. -Both the command and `OutboxRelay`'s constructor **refuse a `PostgresEventPublisher` as the downstream** — +Both the command and `OutboxRelay`'s constructor **refuse the outbox writer (`DatabaseEventPublisher` or `PostgresEventPublisher`) as the downstream** — that would re-INSERT `PENDING` rows into the very same outbox, an infinite loop — throwing a `LogicException` / printing a clear error instead. The refusal applies however the downstream was named: alias, class-string or container binding. The relay is for genuinely bridging to a *different* broker (e.g. you want @@ -420,18 +426,26 @@ anonymous Pest closure, so PHPStan can see `skipUnlessDocker()` statically): | Adapter | Required extension | Environment variable | |---|---|---| | RabbitMQ | — (`php-amqplib` is a hard Composer dependency) | `FIREFLY_RABBITMQ_DSN` | -| Postgres | `ext-pdo_pgsql` | `FIREFLY_PG_DSN` (parsed as `host=…;port=…;dbname=…;user=…;password=…`) | +| Postgres | `ext-pdo_pgsql` | `FIREFLY_PG_DSN` (`host=…;port=…;dbname=…;user=…;password=…` or a URL, parsed by `IntegrationDsn`) | | Kafka | `ext-rdkafka` | `FIREFLY_KAFKA_BROKERS` | +| Database outbox, dead letters, locks | `pdo_pgsql` / `pdo_mysql` / `pdo_sqlsrv` | `FIREFLY_PG_DSN`, `FIREFLY_MYSQL_DSN`, `FIREFLY_MARIADB_DSN`, `FIREFLY_SQLSRV_DSN` | +| Redis (CacheLock) | — (`predis/predis`, dev) | `FIREFLY_REDIS_URL` | + +The last two rows cover the database-agnostic suites (the outbox, the dead-letter stores, the lock providers and +the data layer on real servers) and `CacheLock` over Redis: they need no Docker check, only the variable and the +client. Missing extension, missing environment variable, or no Docker daemon reachable — any one of these skips the suite with a clear message, so `composer check` and ordinary CI runs never depend on a live broker. Run them explicitly (with the broker/extension/Docker prerequisites satisfied) via: ```bash -FIREFLY_PG_DSN="host=127.0.0.1;port=5432;dbname=firefly;user=postgres;password=postgres" \ - vendor/bin/pest --group=integration +FIREFLY_PG_DSN="host=127.0.0.1;port=5432;dbname=firefly;user=firefly;password=firefly" \ + composer test:integration ``` +CI runs one lane per server and one for the brokers — see [Integration Testing](integration-testing.md#ci-lanes). + ## Example: switching an app to the Postgres outbox Set the provider, and uncomment the `postgres` block the reference already ships at its defaults — `relay` is @@ -454,10 +468,11 @@ optional and only read by `firefly:outbox:relay`, so leave it out for terminal i ``` ```bash -composer require firefly/eda-postgres php artisan migrate # creates firefly_eda_outbox php artisan firefly:eda:consume # terminal in-process delivery — run this as a long-lived worker ``` +On any other database, use `provider=database` — see [EDA Database Outbox](eda-database.md). + No application code changes: the same `#[EventListener]` handlers and the same `EventPublisher::publish()` call sites that worked against `memory`/`queue` now run against a durable, same-transaction outbox. diff --git a/docs/modules/eda-database.md b/docs/modules/eda-database.md new file mode 100644 index 00000000..18c6e11e --- /dev/null +++ b/docs/modules/eda-database.md @@ -0,0 +1,94 @@ +# EDA Database Outbox + +`firefly/eda-database` is the portable same-transaction outbox behind `firefly.eda.provider=database`. An +`EventPublisher::publish()` — and every domain event, through the pre-commit hook — writes one +`firefly_eda_outbox` row on the aggregate's own connection inside its open transaction, so the event commits or +rolls back with the aggregate on PostgreSQL, MySQL, MariaDB, SQL Server or SQLite. `php artisan firefly:eda:consume` +delivers committed rows to your `#[EventListener]` handlers; the optional `php artisan firefly:outbox:relay` +forwards them to a second broker. Rows are claimed by **leases**, so any number of consumers and relays may run +against one outbox. `firefly.eda.provider=postgres` ([EDA Brokers](eda-brokers.md)) is this package plus +`pg_notify` on write and a `LISTEN` wake-up in the consumer. + +## Switching an application to the database outbox + +Set the provider, uncomment the block the reference ships at its defaults, migrate, and run a consumer: + + +```php + // 'database' => [ + // 'connection' => 'mysql', + // 'channel' => 'firefly_eda_events', + // 'max-attempts' => 3, + // 'claim-strategy' => env('FIREFLY_EDA_CLAIM_STRATEGY', 'auto'), + // 'lease-seconds' => 300, + // 'poll-interval-ms' => 500, + // 'relay' => [ + // 'downstream-provider' => 'rabbitmq', + // ], + // ], +``` + +```bash +php artisan migrate # creates firefly_eda_outbox (and adds the lease columns to an older one) +php artisan firefly:eda:consume # terminal in-process delivery — run one or more as long-lived workers +``` + +## Configuration + +| Key | Default | Meaning | +|---|---|---| +| `firefly.eda.provider` | `memory` | `database` selects this package; `postgres` selects the Postgres flavour, which reads the same keys. | +| `firefly.eda.database.connection` | the default connection | The connection the outbox writer (outside a transaction), the consumer and the relay use. A domain event is always written on the aggregate's own connection. Alias: `firefly.eda.postgres.connection`. | +| `firefly.eda.database.channel` | `firefly_eda_events` | The value of the row's `channel` column (and, under `postgres`, the `LISTEN`/`NOTIFY` channel). Alias: `firefly.eda.postgres.channel`. | +| `firefly.eda.database.max-attempts` | `3` | Failed deliveries (consumer) or forwards (relay) a row tolerates before it is marked `FAILED`. Alias: `firefly.eda.postgres.max_attempts`. | +| `firefly.eda.database.claim-strategy` | `auto` | `auto`, `skip-locked`, `read-past` or `optimistic-lease` — see below. | +| `firefly.eda.database.lease-seconds` | `300` | How long a claimed row belongs to its claimant. Size it above your slowest listener. | +| `firefly.eda.database.poll-interval-ms` | `500` | How long an idle consumer sleeps between claims (`postgres` waits on `NOTIFY` instead). | +| `firefly.eda.database.relay.downstream-provider` | *(unset)* | Only read by `firefly:outbox:relay`: `rabbitmq`, `kafka`, an `EventPublisher` class-string, or a container id. Alias: `firefly.eda.postgres.relay.downstream_provider`. | + +When a `firefly.eda.database.*` key and its `firefly.eda.postgres.*` alias are both set, the database key wins, so an +application can move between the two providers without losing its settings. + +## Claim strategies + +A claim leases rows to one claimant: the strategy writes `claimed_by` and `claimed_until` on the rows it picked and +commits that before any listener runs or any forward is attempted. A row is claimable while it is `PENDING` and its +lease is empty or over; acknowledging, failing or relaying a row happens only while the claimant still holds it. +The strategies differ only in how they pick rows without two claimants picking the same one: + +| Strategy | How it picks | Chosen by `auto` on | +|---|---|---| +| `skip-locked` | `SELECT … FOR UPDATE SKIP LOCKED` in a short claim transaction (MySQL and MariaDB run it `READ COMMITTED` so publishers never wait on its gap locks) | PostgreSQL; MySQL 8.0.1+; MariaDB 10.6+ | +| `read-past` | `SELECT TOP n … WITH (UPDLOCK, READPAST, ROWLOCK)` in a short claim transaction | SQL Server | +| `optimistic-lease` | an unlocked read, then one conditional `UPDATE … WHERE status = 'PENDING' AND (claimed_until IS NULL OR claimed_until < now)` per row; the row is won when exactly one row changed | SQLite; any server the probe does not recognise; MySQL before 8.0.1; MariaDB before 10.6 | + +`auto` reads the server's version (`DatabaseCapabilities` in `firefly/data`), never the driver's name: a `mysql` +connection whose server is MariaDB is recognised as MariaDB. A forced strategy the server cannot run fails at the +first claim with an error naming the server and its version — it is never downgraded silently. The strategy is +resolved at the first claim, so booting an application opens no connection to find out. + +Lease instants are the application's clock in UTC at millisecond precision; keep your nodes' clocks synchronised +(NTP) well within `lease-seconds`. + +## Delivery and failure + +- **Consumer** (`firefly:eda:consume`): claims one row, delivers it to the `SubscriberRegistry`, then marks it + `PUBLISHED`. A listener that throws counts an attempt and frees the row for an immediate retry, or marks it + `FAILED` past `max-attempts`. A row whose JSON cannot be decoded is dead-lettered at once (`FAILED`, no retry). +- **Relay** (`firefly:outbox:relay`): claims a batch, forwards each row to the downstream broker inside the trace + the row carries, and marks it `PUBLISHED` (or counts the failure). It refuses the outbox writer as its downstream. +- **Several workers**: each row is delivered once while its worker lives within its lease. A worker that crashes + mid-delivery leaves its row leased until the lease ends; then another worker delivers it — across a crash, + delivery is at-least-once, so listeners should be idempotent. +- **Health**: under `provider=database` the actuator reports an `outbox` component — the driver, the strategy in + use and the `PENDING` backlog, or `DOWN` with the error. + +## Moving from `firefly/eda-postgres` + +Nothing to change: `provider=postgres` keeps its beans, its health indicator and its table, and is now this outbox +plus the two Postgres optimizations. The classes that moved here keep their old names as deprecated subclasses for +one release (`Firefly\Eda\Postgres\Outbox\OutboxSchema`, `OutboxRow`, `OutboxRelay`, `RelayDownstream`, +`Firefly\Eda\Postgres\Console\OutboxRelayCommand`). Run `php artisan migrate` once to add the lease columns. + +See the [database support matrix](../database-support.md) for what each server does, and [EDA Brokers](eda-brokers.md) +for the broker adapters. diff --git a/docs/modules/eda.md b/docs/modules/eda.md index e4ec7356..8c3833a8 100644 --- a/docs/modules/eda.md +++ b/docs/modules/eda.md @@ -194,9 +194,14 @@ interface DeadLetterStore } ``` -`InMemoryDeadLetterStore` (the `#[ConditionalOnMissingBean]` default) is an in-process, append-only list of -`DeadLetterEntry` (envelope + exception class + exception message) — nothing is silently lost, but it does -not survive past the process. A durable adapter is SP-4 territory. +`firefly.eda.dead-letter.store` selects the store under every provider: `memory` (the default, +`InMemoryDeadLetterStore`, an in-process, append-only list of `DeadLetterEntry` — envelope + exception class + +exception message — lost when the process exits) or `database` (`DatabaseDeadLetterStore`, one +`firefly_eda_dead_letters` row per exhausted envelope on `firefly.eda.dead-letter.connection`; run +`php artisan migrate`). Both implement `ReplayableDeadLetterStore` — `find(DeadLetterQuery)` and `remove(id)` — +which `php artisan firefly:eda:dead-letters:replay` uses to republish entries, oldest first, through the bound +`EventPublisher` to their original destination (`--id`, `--destination`, `--event-type`, `--limit`, `--dry-run`, +`--keep`). An application-bound `DeadLetterStore` still wins. ## Serializer seam @@ -239,14 +244,16 @@ fails open wherever a publisher is built by autowiring instead. | Key | Type | Default | Meaning | |---|---|---|---| -| `firefly.eda.provider` | `memory`\|`queue`\|`rabbitmq`\|`postgres`\|`kafka` | `memory` | `EdaAutoConfiguration` selects `InMemoryEventBus` or `QueueEventBus`; the three broker values are honoured by the adapter packages (see [EDA brokers](eda-brokers.md)) and read here as "not queue". | +| `firefly.eda.provider` | `memory`\|`queue`\|`database`\|`rabbitmq`\|`postgres`\|`kafka` | `memory` | `EdaAutoConfiguration` selects `InMemoryEventBus` or `QueueEventBus`; the other values are honoured by the adapter packages — `database` by [EDA Database Outbox](eda-database.md), `rabbitmq`, `postgres` and `kafka` by [EDA Brokers](eda-brokers.md) — and read here as "not queue". | | `firefly.eda.serialization_format` | string | `json` | Selects `Serializer`; anything but `json` throws `SerializationException`. | | `firefly.eda.retries` | int | `0` | Handler-level retry count applied to every `#[EventListener]` by `EventListenerWiringPass`. | | `firefly.eda.retry_delay` | float (seconds) | `0.0` | Linear-backoff base delay (`retry_delay * attempt`). | +| `firefly.eda.dead-letter.store` | `memory`\|`database` | `memory` | Where exhausted envelopes go — see [Retry / DLQ model](#retry-dlq-model). | +| `firefly.eda.dead-letter.connection` | string\|null | `null` | The connection `DatabaseDeadLetterStore` writes on (`null` = the default connection). | | `firefly.eda.queue.connection` | string\|null | `null` (default connection) | Queue connection `QueueEventBus`/`DispatchEventJob` dispatch onto, when `provider=queue`. | | `firefly.eda.queue.name` | string\|null | `null` (default queue) | Queue name, when `provider=queue`. | | `firefly.eda.destinations` | list\ | `[]` | The broker destinations `php artisan firefly:eda:consume` binds when `--destination` is not passed. Read **only** by that command; it must be a list of strings or the command throws a `ConfigurationException`. | -| `firefly.eda.tracing.brokers.enabled` | bool | `true` | Whether the rabbitmq/kafka/postgres publishers may stamp a `traceparent` on the envelope they hand to the transport. Read in ONE place (`BrokerTracing`), by the adapters' publisher beans and by the relay's downstream overrides. Set it to `false` to keep in-process spans while putting no trace identifier on a third party's wire; it can only ever downgrade the bound `EdaTracing` to the no-op, never switch tracing on. | +| `firefly.eda.tracing.brokers.enabled` | bool | `true` | Whether the rabbitmq/kafka/database/postgres publishers may stamp a `traceparent` on the envelope they hand to the transport. Read in ONE place (`BrokerTracing`), by the adapters' publisher beans and by the relay's downstream overrides. Set it to `false` to keep in-process spans while putting no trace identifier on a third party's wire; it can only ever downgrade the bound `EdaTracing` to the no-op, never switch tracing on. | !!! note "A publish `destination` is a call-site argument, not a config key" `EventPublisher::publish(string $destination, ...)` takes the destination explicitly at the call site — @@ -308,13 +315,6 @@ final class OrderService These are carried-forward, documented limitations of the M9 shipment — not bugs: -- **Real brokers are OUT.** Only the in-memory and Laravel-queue adapters ship. Kafka/RabbitMQ/Postgres/Redis - land as their own `firefly/eda-` packages at **SP-4**, each gated behind - `#[ConditionalOnProperty('firefly.eda.provider', '')]`, patterned on `firefly/scheduling-postgres`. -- **The durable transactional outbox is deferred to SP-4.** M9 gives at-least-once delivery within a - process/queue, not a DB-transaction-to-broker durability guarantee. The substrate is already built and - untouched — M8's `DB::afterCommit` hook (`DomainEventDispatcher`) and the in-house `PgAdvisoryLock` - (`firefly/scheduling-postgres`) — so SP-4's `firefly/eda-postgres` relay is a clean future drop-in. - **Avro/Protobuf serializers, event `filter`, and an event circuit-breaker are thin/deferred seams.** JSON is the only shipped serializer; the others throw `SerializationException` until their own opt-in packages land. Filter and circuit-breaker are not implemented in M9. Publisher actuator-health wiring is **M12**. diff --git a/docs/modules/integration-testing.md b/docs/modules/integration-testing.md index 3202b236..13a2b6f8 100644 --- a/docs/modules/integration-testing.md +++ b/docs/modules/integration-testing.md @@ -34,6 +34,35 @@ it('reads from a real Postgres container', function () { }); ``` +## Connecting to a real backend: `IntegrationDsn` + +Every integration test reads its backend from the environment through one helper, +`Firefly\Testing\Integration\IntegrationDsn`, which accepts both `host=…;port=…;dbname=…;user=…;password=…` and +URL-form DSNs (`pgsql://user:pass@host:5432/db`, `amqp://guest:guest@host:5672/%2f`), refuses anything else, and +treats a blank variable as unset — so a missing backend skips its tests and never fails them: + +| Variable | Backend | Lane | +|---|---|---| +| `FIREFLY_PG_DSN` | PostgreSQL | `pgsql` | +| `FIREFLY_MYSQL_DSN` | MySQL | `mysql` | +| `FIREFLY_MARIADB_DSN` | MariaDB | `mariadb` | +| `FIREFLY_SQLSRV_DSN` | SQL Server (`encrypt` defaults to `yes`; `trust_server_certificate` defaults to `false`, so a self-signed local or CI server must set `trust_server_certificate=true` in its DSN) | `sqlsrv` | +| `FIREFLY_RABBITMQ_DSN`, `FIREFLY_KAFKA_BROKERS`, `FIREFLY_REDIS_URL` | the brokers | `brokers` | + +`IntegrationDsn::connectionConfig($lane)` returns a Laravel connection config, `IntegrationDsn::connect($lane)` a +standalone connection (what a second test process uses), `IntegrationDsn::skipReason($lane)` the reason a lane +cannot run here (or `null`). The `sqlite` lane needs no variable: it runs on a temporary file in WAL mode, so two +processes can race over it. + +## CI lanes + +`composer test:integration` runs the `integration` group. CI runs it in an `integration` job per database server +(PostgreSQL 16, MySQL 8.4, MariaDB 11, SQL Server 2022), each exporting only its own DSN, and in a `brokers` job +(RabbitMQ, Kafka with `ext-rdkafka`, Redis). Each job also sets `FIREFLY_INTEGRATION_LANE`, and +`tests/Integration/IntegrationLaneTest.php` then FAILS if that lane's variables are missing or its servers do not +answer — a lane cannot go green by skipping everything. `docker-compose.integration.yml` at the repository root +starts the same services locally (SQL Server behind the `sqlsrv` profile). + ## `RequiresDocker` + `skipUnlessDocker()` `Firefly\Testing\Integration\RequiresDocker` is a trait (`@mixin PHPUnit\Framework\TestCase`) that marks a test @@ -180,5 +209,7 @@ available: vendor/bin/pest --group=integration ``` -Docker-less environments that invoke `vendor/bin/pest --group=integration` still get a clean run: every test -using `RequiresDocker::skipUnlessDocker()` reports **skipped**, not failed. +`composer test:integration` is the same run with the memory limit collection needs. Docker-less environments +that invoke `vendor/bin/pest --group=integration` still get a clean run: every test using +`RequiresDocker::skipUnlessDocker()` — and every test whose `FIREFLY_*` variable is unset — reports +**skipped**, not failed. diff --git a/docs/modules/messaging.md b/docs/modules/messaging.md index 7f72bd28..eb28a6cd 100644 --- a/docs/modules/messaging.md +++ b/docs/modules/messaging.md @@ -163,6 +163,12 @@ This is a **separate class** from eda's `DeadLetterStore` (`Firefly\Messaging\De `Firefly\Eda\DeadLetter\DeadLetterStore`) — no shared code, no cross-package edge; it wraps a raw `Message` rather than an `EventEnvelope`, faithful to the two ports' different payload shapes. +`firefly.messaging.dead-letter.store` selects it: `memory` (default) or `database` (`DatabaseDeadLetterStore`, one +`firefly_messaging_dead_letters` row per message on `firefly.messaging.dead-letter.connection`, the raw bytes +base64-encoded so they round-trip on every driver; run `php artisan migrate`). +`php artisan firefly:messaging:dead-letters:replay` republishes entries to their original topic (`--id`, `--topic`, +`--limit`, `--dry-run`, `--keep`). + ## Config keys | Key | Type | Default | Meaning | @@ -170,6 +176,8 @@ rather than an `EventEnvelope`, faithful to the two ports' different payload sha | `firefly.messaging.provider` | `memory`\|`queue` | `memory` | Selects `InMemoryMessageBroker` or `QueueMessageBroker` (`MessagingAutoConfiguration`). | | `firefly.messaging.queue.connection` | string\|null | `null` (default connection) | Queue connection `QueueMessageBroker`/`DispatchMessageJob` dispatch onto, when `provider=queue`. | | `firefly.messaging.queue.name` | string\|null | `null` (default queue) | Queue name, when `provider=queue`. | +| `firefly.messaging.dead-letter.store` | `memory`\|`database` | `memory` | Where a `#[MessageListener(deadLetterTopic: …)]` message goes once its retries are exhausted — see above. | +| `firefly.messaging.dead-letter.connection` | string\|null | `null` | The connection `DatabaseDeadLetterStore` writes on (`null` = the default connection). | There is no `firefly.messaging.retries`/`retry_delay` config key — retry/DLQ policy lives entirely on each `#[MessageListener]` attribute (see above), not in configuration. diff --git a/docs/modules/scheduling-database.md b/docs/modules/scheduling-database.md new file mode 100644 index 00000000..bb1581c1 --- /dev/null +++ b/docs/modules/scheduling-database.md @@ -0,0 +1,41 @@ +# Scheduling Database Locks + +`firefly/scheduling-database` adds two `DistributedLock` backends for `#[Scheduled(lock: …)]` tasks, so a task runs +on at most one node per tick without Redis or PostgreSQL. See [Scheduling](scheduling.md) for the port, the other +backends and how a locked task is guarded. + +| `firefly.scheduling.lock.provider` | Class | Works on | TTL | Holds a connection while the task runs | +|---|---|---|---|---| +| `database` | `DatabaseLeaseLock` | any Laravel database connection | honoured on every driver | no | +| `mysql` | `MySqlNamedLock` | MySQL, MariaDB | ignored (session-scoped) | yes — released when the session closes | + +## `DatabaseLeaseLock` + +One row per lock name in `firefly_scheduling_locks` (`name`, `locked_by`, `locked_until`, `locked_at`). Acquiring is +a conditional `UPDATE` that takes over a lease whose `locked_until` has passed, or — when the row does not exist — +an `INSERT`; a duplicate key means another node holds a live lease. The `INSERT` runs inside a savepoint when the +caller already has a transaction open, so a lost race never aborts the caller's unit of work (on PostgreSQL a failed +statement otherwise poisons the whole transaction). Releasing deletes the row only while it still carries this +node's acquisition token, so a task that ran past its TTL cannot release the lease another node has taken since. A +lock name is at most 255 characters; a longer one is refused rather than truncated. + +Run `php artisan migrate` after selecting the provider. Lease instants are the application's clock in UTC, so keep +your nodes' clocks synchronised well within the lock TTLs. + +## `MySqlNamedLock` + +`GET_LOCK(name, 0)` / `RELEASE_LOCK(name)` — the MySQL-family counterpart of `PgAdvisoryLock`. The lock belongs to +the database session and is freed when the connection closes; there is no table and no TTL. Names longer than the +server's 64-character limit are hashed to `firefly:`, identically on every node. The connection must be a +`mysql` or `mariadb` connection, or the first acquisition fails with a configuration error. + +## Configuration + +| Key | Default | Meaning | +|---|---|---| +| `firefly.scheduling.lock.provider` | `none` | `database` or `mysql` select this package's backends. | +| `firefly.scheduling.lock.database.connection` | the default connection | The connection `DatabaseLeaseLock` writes its lease rows on. | +| `firefly.scheduling.lock.mysql.connection` | the default connection | The MySQL or MariaDB connection `MySqlNamedLock` takes its named locks on. | + +Both backends are proven on every server they support by the `integration` CI lanes — see the +[database support matrix](../database-support.md). diff --git a/docs/modules/scheduling.md b/docs/modules/scheduling.md index b2245b37..643ff837 100644 --- a/docs/modules/scheduling.md +++ b/docs/modules/scheduling.md @@ -33,6 +33,8 @@ always wins): |---|---|---|---| | *(unset)* / `none` | `NoneLock` | `firefly/scheduling` | **The default.** Every `tryAcquire()` succeeds and `release()` is a no-op — correct for a single-instance deployment (the only "node" always wins) and for a bare skeleton with no lock infrastructure, but provides **no real mutual exclusion** across nodes. | | `cache` | `CacheLock` | `firefly/scheduling` | Wraps Laravel's atomic `Cache::lock()`. Works over any lock-capable driver (`database`, `redis`, `memcached`, and `array` for tests). The `null` cache driver grants **no-op** locks (every acquire "succeeds" with no real exclusion, same as `NoneLock`); `array` is **per-process only** — it does not coordinate across separate PHP-FPM workers or hosts. Production multi-node deployments need `database` or `redis`. | +| `database` | `DatabaseLeaseLock` | `firefly/scheduling-database` | A lease row per lock name on any database connection; the TTL is honoured on every driver. See [Scheduling Database Locks](scheduling-database.md). | +| `mysql` | `MySqlNamedLock` | `firefly/scheduling-database` | MySQL/MariaDB `GET_LOCK`, session-scoped, no TTL. | | `postgres` | `PgAdvisoryLock` | `firefly/scheduling-postgres` | Session-scoped Postgres advisory lock (see below). Included in the root library; requires a PostgreSQL connection and `ext-pdo_pgsql`. | `CacheLock::tryAcquire()` throws a `ConfigurationException` up front if the configured cache store isn't a @@ -235,7 +237,9 @@ there would abort the whole `schedule:run` for that minute rather than skip one | Key | Default | Meaning | |---|---|---| -| `firefly.scheduling.lock.provider` | `'none'` | The `DistributedLock` backend a locked task uses: `none`, `cache`, or `postgres` (needs `firefly/scheduling-postgres`) — see [The `DistributedLock` port](#the-distributedlock-port). | +| `firefly.scheduling.lock.provider` | `'none'` | The `DistributedLock` backend a locked task uses: `none`, `cache`, `database`, `mysql` (both need `firefly/scheduling-database`) or `postgres` (needs `firefly/scheduling-postgres`) — see [The `DistributedLock` port](#the-distributedlock-port). | +| `firefly.scheduling.lock.database.connection` | the default connection | The connection `DatabaseLeaseLock` writes its lease rows on. | +| `firefly.scheduling.lock.mysql.connection` | the default connection | The MySQL or MariaDB connection `MySqlNamedLock` takes its named locks on. | | `firefly.scheduling.initial-delay.enabled` | `true` | Apply `#[Scheduled(initialDelay:)]`. `false` **refuses to boot** an application whose manifest carries one, rather than accepting the parameter and ignoring it. | | `firefly.scheduling.initial-delay.store` | `''` | Cache store the anchor lives in; `''` is the default store. Must be shared across processes (redis/memcached/database) wherever the scheduler is cron-driven. | | `firefly.scheduling.initial-delay.release` | `''` | Names this deployment, so every release arms its own window. Empty keeps the permanent anchor — one window per cache key, for the life of the key. | diff --git a/docs/modules/transactional.md b/docs/modules/transactional.md index 41b448ed..f82fd159 100644 --- a/docs/modules/transactional.md +++ b/docs/modules/transactional.md @@ -139,7 +139,9 @@ max_execution_time` (milliseconds, `SELECT`s only) or mariadb `SET SESSION max_s 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 +only knob sqlite has) restored to the configured `busy_timeout`, sqlsrv `SET LOCK_TIMEOUT` (milliseconds, a +lock wait only — error 1222, translated to `CannotAcquireLockException`) with the previous `@@LOCK_TIMEOUT` +read first and restored — 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. @@ -386,7 +388,8 @@ $template->execute($work, new TransactionalDescriptor( 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 `SELECT`s only (mariadb's `max_statement_time` interrupts any - statement). + statement). SQL Server has no server-side knob for a statement that is merely slow: `LOCK_TIMEOUT` bounds + only the time a statement waits for a lock. - **`#[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 diff --git a/mkdocs.yml b/mkdocs.yml index df54cf58..f1d4de6e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -132,6 +132,7 @@ nav: - Resilience & Scheduling: - Resilience: modules/resilience.md - Scheduling: modules/scheduling.md + - Scheduling Database Locks: modules/scheduling-database.md - Data & Domain: - Domain (DDD): modules/domain.md - Data & Repositories: modules/data.md @@ -140,6 +141,7 @@ nav: - Eventing & Messaging: - Event-Driven Architecture: modules/eda.md - EDA Brokers: modules/eda-brokers.md + - EDA Database Outbox: modules/eda-database.md - Messaging: modules/messaging.md - CQRS: - CQRS (Command/Query): modules/cqrs.md @@ -163,6 +165,7 @@ nav: - Installer: modules/installer.md - Reference: - CLI Reference: cli.md + - Database Support Matrix: database-support.md - Feature Flag Contract: feature-flags-contract.md - Versioning: versioning.md - Contributing: diff --git a/packages/cli/tests/Support/FoundationFlowPostgresTestCase.php b/packages/cli/tests/Support/FoundationFlowPostgresTestCase.php index 1255641d..4b15d53f 100644 --- a/packages/cli/tests/Support/FoundationFlowPostgresTestCase.php +++ b/packages/cli/tests/Support/FoundationFlowPostgresTestCase.php @@ -4,6 +4,7 @@ namespace Firefly\Cli\Tests\Support; +use Firefly\Testing\Integration\IntegrationDsn; use Firefly\Testing\Integration\RequiresDocker; use Illuminate\Contracts\Config\Repository; use Illuminate\Foundation\Application; @@ -14,8 +15,8 @@ * The @group integration variant of the Foundation harness: boots the SAME cached-path persistence slice * (WidgetRepository derived query + FoundationWriter #[Transactional] commit) but against REAL Postgres instead of * sqlite :memory:. Docker-gated via RequiresDocker (skips when Docker is absent). The Postgres backend is described - * by FIREFLY_PG_DSN (host=…;port=…;dbname=…;user=…;password=…) — the exact opt-in mechanism scheduling-postgres' - * PgAdvisoryLockIntegrationTest uses — wrapped in a duck-typed container and mapped into the Laravel `testing` + * by FIREFLY_PG_DSN (host=…;port=…;dbname=…;user=…;password=…) — parsed by firefly/testing's IntegrationDsn, the one + * parser every integration suite shares — wrapped in a duck-typed container and mapped into the Laravel `testing` * connection through firefly/testing's fireflyConfigFor() @ServiceConnection helper (the same shape a * testcontainers container exposes, so dropping in testcontainers/testcontainers later is a one-line swap). Only * the DB connection differs from the sqlite pass; every other layer boots identically. @@ -80,17 +81,11 @@ protected function defineSqliteMemory(Application $app): void */ private function resolvePostgres(): object { - $dsn = getenv('FIREFLY_PG_DSN'); - if (! is_string($dsn) || $dsn === '') { + $parts = IntegrationDsn::parts(IntegrationDsn::PGSQL); + if ($parts === null) { $this->markTestSkipped('Set FIREFLY_PG_DSN (host=…;port=…;dbname=…;user=…;password=…) to run the @group integration Postgres pass.'); } - $parts = []; - foreach (explode(';', $dsn) as $kv) { - [$key, $value] = array_pad(explode('=', $kv, 2), 2, ''); - $parts[$key] = $value; - } - return new class($parts) { /** @param array $parts */ diff --git a/packages/data/src/Capability/DatabaseCapabilities.php b/packages/data/src/Capability/DatabaseCapabilities.php new file mode 100644 index 00000000..d0387639 --- /dev/null +++ b/packages/data/src/Capability/DatabaseCapabilities.php @@ -0,0 +1,97 @@ +getConfig('driver'); + $driver = is_string($configured) ? $configured : ''; + + try { + $pdo = $connection->getPdo(); + $version = $pdo->getAttribute(PDO::ATTR_SERVER_VERSION); + + if ($driver === '') { + $reported = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + $driver = is_string($reported) ? $reported : ''; + } + } catch (Throwable) { + $version = ''; + } + + return self::fromVersion($driver, is_string($version) ? $version : ''); + } + + public static function fromVersion(string $driver, string $serverVersion): self + { + return new self($driver, $serverVersion, $driver === 'mariadb' || stripos($serverVersion, 'mariadb') !== false); + } + + public function version(): string + { + $raw = $this->serverVersion; + + if ($this->mariaDb && str_starts_with($raw, '5.5.5-')) { + $raw = substr($raw, strlen('5.5.5-')); + } + + return preg_match('/^\d+(?:\.\d+){0,2}/', $raw, $matches) === 1 ? $matches[0] : '0'; + } + + public function isMySqlFamily(): bool + { + return $this->driver === 'mysql' || $this->driver === 'mariadb'; + } + + public function supportsSkipLocked(): bool + { + return match (true) { + $this->driver === 'pgsql' => true, + $this->isMySqlFamily() && $this->mariaDb => version_compare($this->version(), '10.6', '>='), + $this->isMySqlFamily() => version_compare($this->version(), '8.0.1', '>='), + default => false, + }; + } + + public function supportsReadPast(): bool + { + return $this->driver === 'sqlsrv'; + } + + public function describe(): string + { + return $this->driver.' '.($this->serverVersion === '' ? '(unknown version)' : $this->serverVersion); + } +} diff --git a/packages/data/src/Transaction/Timeout/StatementTimeoutApplier.php b/packages/data/src/Transaction/Timeout/StatementTimeoutApplier.php index b08b17fa..8097e4c8 100644 --- a/packages/data/src/Transaction/Timeout/StatementTimeoutApplier.php +++ b/packages/data/src/Transaction/Timeout/StatementTimeoutApplier.php @@ -35,6 +35,11 @@ * sqlite PDO::ATTR_TIMEOUT = , the busy timeout — the only knob sqlite has (a statement that is * merely slow is not interruptible). PDO cannot read the attribute back, so the restore goes * to the connection's configured `busy_timeout` (ms) or PDO_SQLITE's own 60 s default. + * sqlsrv SET LOCK_TIMEOUT — how long a statement waits for a lock before failing with error 1222 + * (translated to CannotAcquireLockException). SQL Server has no server-side knob that interrupts a + * statement that is merely slow, so a lock wait is the part of the budget the database can enforce. + * The setting is SESSION-scoped, so the previous value (@@LOCK_TIMEOUT, -1 = wait forever by + * default) is read first and put back afterwards, exactly like the mysql variables. * anything else nothing. * * Every statement is best-effort, exactly like the isolation SET: a driver that refuses it does not fail the @@ -58,6 +63,7 @@ public function apply(Connection $connection, int $seconds): Closure 'mysql' => $this->isMaria($connection) ? $this->mariadb($connection, $seconds) : $this->mysql($connection, $seconds), 'mariadb' => $this->mariadb($connection, $seconds), 'sqlite' => $this->sqlite($connection, $seconds), + 'sqlsrv' => $this->sqlsrv($connection, $seconds), default => $noop, }; } catch (Throwable) { @@ -161,6 +167,44 @@ private function sqlite(Connection $connection, int $seconds): Closure }; } + /** + * @return Closure(): void + */ + private function sqlsrv(Connection $connection, int $seconds): Closure + { + $noop = static function (): void {}; + + try { + $row = $connection->selectOne('select @@lock_timeout as value'); + // unprepared(), never statement(): pdo_sqlsrv runs a prepared statement through sp_prepexec, and SQL Server + // restores a SET made inside it when that call returns, so the session would keep waiting forever. + /** @var literal-string $set an integer formatted by %d: nothing but digits reaches the batch */ + $set = sprintf('set lock_timeout %d', $seconds * 1000); + $connection->unprepared($set); + } catch (Throwable) { + return $noop; + } + + $previous = is_object($row) ? (get_object_vars($row)['value'] ?? null) : null; + + if (! is_numeric($previous)) { + // Nothing came back (pretend(), a server that answers nothing): there is no value to put back. + return $noop; + } + + $restore = (int) $previous; + + return static function () use ($connection, $restore): void { + try { + /** @var literal-string $reset an integer formatted by %d */ + $reset = sprintf('set lock_timeout %d', $restore); + $connection->unprepared($reset); + } catch (Throwable) { + // Best-effort, see the class docblock. + } + }; + } + /** * One step that runs every restore in reverse order of application, skipping the variables that were * never set. diff --git a/packages/data/tests/Capability/DatabaseCapabilitiesTest.php b/packages/data/tests/Capability/DatabaseCapabilitiesTest.php new file mode 100644 index 00000000..47a2590d --- /dev/null +++ b/packages/data/tests/Capability/DatabaseCapabilitiesTest.php @@ -0,0 +1,52 @@ +supportsSkipLocked())->toBe($skipLocked) + ->and($capabilities->mariaDb)->toBe($maria) + ->and($capabilities->version())->toBe($numeric); +})->with([ + 'postgres 16' => ['pgsql', '16.15', true, false, '16.15'], + 'postgres, version unreadable' => ['pgsql', '', true, false, '0'], + 'mysql 8.4' => ['mysql', '8.4.11', true, false, '8.4.11'], + 'mysql 8.0.1, the first with SKIP LOCKED' => ['mysql', '8.0.1', true, false, '8.0.1'], + 'mysql 8.0.0' => ['mysql', '8.0.0', false, false, '8.0.0'], + 'mysql 5.7' => ['mysql', '5.7.44-log', false, false, '5.7.44'], + 'mariadb 11 on the mariadb driver' => ['mariadb', '11.8.9-MariaDB-ubu2404', true, true, '11.8.9'], + 'mariadb 10.6' => ['mariadb', '10.6.18-MariaDB', true, true, '10.6.18'], + 'mariadb 10.5' => ['mariadb', '10.5.25-MariaDB', false, true, '10.5.25'], + 'mariadb behind a mysql driver, replication prefix' => ['mysql', '5.5.5-10.11.6-MariaDB-1:10.11.6+maria~ubu2204', true, true, '10.11.6'], + 'mariadb 10.4 behind a mysql driver' => ['mysql', '5.5.5-10.4.34-MariaDB', false, true, '10.4.34'], + 'sqlite' => ['sqlite', '3.46.1', false, false, '3.46.1'], + 'sqlsrv' => ['sqlsrv', '16.00.4135', false, false, '16.00.4135'], + 'an unknown driver' => ['oracle', '23.4', false, false, '23.4'], +]); + +it('knows READPAST is a SQL Server table hint and nothing else', function () { + expect(DatabaseCapabilities::fromVersion('sqlsrv', '16.00.4135')->supportsReadPast())->toBeTrue() + ->and(DatabaseCapabilities::fromVersion('pgsql', '16.15')->supportsReadPast())->toBeFalse() + ->and(DatabaseCapabilities::fromVersion('mariadb', '11.4.2-MariaDB')->isMySqlFamily())->toBeTrue() + ->and(DatabaseCapabilities::fromVersion('sqlsrv', '16.00.4135')->isMySqlFamily())->toBeFalse() + ->and(DatabaseCapabilities::fromVersion('mysql', '8.4.11')->describe())->toBe('mysql 8.4.11') + ->and(DatabaseCapabilities::fromVersion('mysql', '')->describe())->toBe('mysql (unknown version)'); +}); + +it('probes a live connection without throwing, reading the version PDO reports', function () { + $capabilities = DatabaseCapabilities::of(new SQLiteConnection(new PDO('sqlite::memory:'), ':memory:')); + + expect($capabilities->driver)->toBe('sqlite') + ->and($capabilities->serverVersion)->toMatch('/^3\.\d+/') + ->and($capabilities->supportsSkipLocked())->toBeFalse(); +}); diff --git a/packages/data/tests/Integration/DatabaseCapabilitiesIntegrationTest.php b/packages/data/tests/Integration/DatabaseCapabilitiesIntegrationTest.php new file mode 100644 index 00000000..23156f3c --- /dev/null +++ b/packages/data/tests/Integration/DatabaseCapabilitiesIntegrationTest.php @@ -0,0 +1,39 @@ +version())->not->toBe('0') + ->and($capabilities->supportsSkipLocked())->toBe(in_array($lane, ['pgsql', 'mysql', 'mariadb'], true)) + ->and($capabilities->supportsReadPast())->toBe($lane === 'sqlsrv') + ->and($capabilities->mariaDb)->toBe($lane === 'mariadb'); + + if ($capabilities->supportsSkipLocked()) { + $schema = $connection->getSchemaBuilder(); + $schema->dropIfExists('firefly_capability_probe'); + $schema->create('firefly_capability_probe', static function (Blueprint $table): void { + $table->bigIncrements('id'); + }); + $connection->table('firefly_capability_probe')->insert(['id' => 1]); + + $rows = $connection->transaction(static fn () => $connection->table('firefly_capability_probe')->lock('for update skip locked')->get()); + + expect($rows)->toHaveCount(1); + $schema->drop('firefly_capability_probe'); + } + + $connection->disconnect(); + })->group('integration')->skip(IntegrationDsn::skipReason($lane) !== null, (string) IntegrationDsn::skipReason($lane)); +} diff --git a/packages/data/tests/Integration/PersistenceExceptionTranslationIntegrationTest.php b/packages/data/tests/Integration/PersistenceExceptionTranslationIntegrationTest.php new file mode 100644 index 00000000..cef091b4 --- /dev/null +++ b/packages/data/tests/Integration/PersistenceExceptionTranslationIntegrationTest.php @@ -0,0 +1,143 @@ +getSchemaBuilder(); + $schema->dropIfExists('firefly_translation_children'); + $schema->dropIfExists('firefly_translation_parents'); + $schema->create('firefly_translation_parents', static function (Blueprint $table): void { + $table->integer('id')->primary(); + $table->string('name'); + }); + $schema->create('firefly_translation_children', static function (Blueprint $table): void { + $table->integer('id')->primary(); + $table->integer('parent_id'); + $table->string('email')->unique(); + $table->string('name'); + $table->foreign('parent_id')->references('id')->on('firefly_translation_parents'); + }); + $connection->table('firefly_translation_parents')->insert(['id' => 1, 'name' => 'p']); + $connection->table('firefly_translation_children')->insert(['id' => 1, 'parent_id' => 1, 'email' => 'a@x.test', 'name' => 'c']); +} + +function translationOf(Connection $connection, Closure $statement): ?Throwable +{ + try { + $statement(); + } catch (Throwable $e) { + return (new PersistenceExceptionTranslator)->translate($e, $connection->getDriverName()); + } + + return null; +} + +/** Make $connection give up on a lock wait quickly, in the server's own dialect. */ +function translationShortLockWait(Connection $connection, string $lane): void +{ + match ($lane) { + 'pgsql' => $connection->statement("set lock_timeout = '300ms'"), + 'mysql', 'mariadb' => $connection->statement('set session innodb_lock_wait_timeout = 1'), + // A prepared SET is undone when sp_prepexec returns on SQL Server; only a direct batch sticks to the session. + 'sqlsrv' => $connection->unprepared('set lock_timeout 300'), + default => null, + }; +} + +foreach (IntegrationDsn::lanes() as $lane) { + it("translates integrity, grammar and lock-wait failures on {$lane}", function () use ($lane): void { + $overrides = $lane === 'sqlite' ? ['database' => IntegrationDsn::sqlitePath('translation'), 'busy_timeout' => 300] : []; + $a = IntegrationDsn::connect($lane, 'translation-a', $overrides); + $b = IntegrationDsn::connect($lane, 'translation-b', $overrides); + translationSchema($a); + + try { + $children = static fn (Connection $c) => $c->table('firefly_translation_children'); + + expect(translationOf($a, static fn () => $children($a)->insert(['id' => 2, 'parent_id' => 1, 'email' => 'a@x.test', 'name' => 'dup'])))->toBeInstanceOf(DuplicateKeyException::class) + ->and(translationOf($a, static fn () => $children($a)->insert(['id' => 1, 'parent_id' => 1, 'email' => 'b@x.test', 'name' => 'pk'])))->toBeInstanceOf(DuplicateKeyException::class) + ->and(translationOf($a, static fn () => $children($a)->insert(['id' => 3, 'parent_id' => 1, 'email' => 'c@x.test', 'name' => null])))->toBeInstanceOf(DataIntegrityViolationException::class) + ->and(translationOf($a, static fn () => $children($a)->insert(['id' => 4, 'parent_id' => 99, 'email' => 'd@x.test', 'name' => 'fk'])))->toBeInstanceOf(DataIntegrityViolationException::class) + ->and(translationOf($a, static fn () => $a->table('firefly_translation_missing')->get()))->toBeInstanceOf(BadSqlGrammarException::class); + + // A lock wait that times out: a holds the row (sqlite: the database) for writing, b gives up. + $a->beginTransaction(); + $a->table('firefly_translation_parents')->where('id', 1)->update(['name' => 'held']); + translationShortLockWait($b, $lane); + $waited = translationOf($b, static fn () => $b->table('firefly_translation_parents')->where('id', 1)->update(['name' => 'waiter'])); + $a->rollBack(); + + expect($waited)->toBeInstanceOf(CannotAcquireLockException::class); + } finally { + $schema = $a->getSchemaBuilder(); + $schema->dropIfExists('firefly_translation_children'); + $schema->dropIfExists('firefly_translation_parents'); + $a->disconnect(); + $b->disconnect(); + } + })->group('integration')->skip(IntegrationDsn::skipReason($lane) !== null, (string) IntegrationDsn::skipReason($lane)); +} + +foreach (['pgsql', 'mysql', 'mariadb', 'sqlsrv'] as $lane) { + it("translates the deadlock victim on {$lane}, whichever process the server picks", function () use ($lane): void { + $connection = IntegrationDsn::connect($lane, 'deadlock-parent'); + $schema = $connection->getSchemaBuilder(); + $schema->dropIfExists('firefly_deadlock_probe'); + $schema->create('firefly_deadlock_probe', static function (Blueprint $table): void { + $table->integer('id')->primary(); + $table->string('name'); + }); + $connection->table('firefly_deadlock_probe')->insert([['id' => 1, 'name' => 'a'], ['id' => 2, 'name' => 'b']]); + + $go = sys_get_temp_dir().'/firefly-deadlock-'.bin2hex(random_bytes(6)); + $racer = new Process([PHP_BINARY, dirname(__DIR__).'/Support/deadlock-racer.php', $lane, $go], timeout: 40); + + try { + $connection->beginTransaction(); + $connection->table('firefly_deadlock_probe')->where('id', 1)->update(['name' => 'parent']); + $racer->start(); + expect($racer->waitUntil(static fn (string $type, string $output): bool => str_contains($output, 'locked')))->toBeTrue($racer->getErrorOutput()); + touch($go); + usleep(300_000); // the racer is now waiting for row 1 + + $parent = translationOf($connection, static fn () => $connection->table('firefly_deadlock_probe')->where('id', 2)->update(['name' => 'parent'])); + if ($parent === null) { + $connection->commit(); + } elseif ($connection->transactionLevel() > 0) { + $connection->rollBack(); + } + + expect($racer->wait())->toBe(0, $racer->getErrorOutput()); + $racerSaw = trim($racer->getOutput()); + + // Exactly one side lost, and it was told so in the kernel's own vocabulary. + expect($parent instanceof DeadlockLoserDataAccessException xor str_ends_with($racerSaw, 'deadlock'))->toBeTrue('parent: '.($parent === null ? 'committed' : $parent::class)."; racer: {$racerSaw}") + ->and($parent === null || $parent instanceof DeadlockLoserDataAccessException)->toBeTrue(); + } finally { + $racer->stop(); + @unlink($go); + if ($connection->transactionLevel() > 0) { + $connection->rollBack(); + } + $schema->dropIfExists('firefly_deadlock_probe'); + $connection->disconnect(); + } + })->group('integration')->skip(IntegrationDsn::skipReason($lane) !== null, (string) IntegrationDsn::skipReason($lane)); +} diff --git a/packages/data/tests/Integration/RepositoryLockingIntegrationTest.php b/packages/data/tests/Integration/RepositoryLockingIntegrationTest.php new file mode 100644 index 00000000..616aa271 --- /dev/null +++ b/packages/data/tests/Integration/RepositoryLockingIntegrationTest.php @@ -0,0 +1,130 @@ +scan([ + 'Firefly\\Data\\Tests\\Fixtures\\Repository\\' => dirname(__DIR__).'/Fixtures/Repository', + ])); +} + +function lockingLane(string $lane): Connection +{ + $overrides = $lane === 'sqlite' ? ['database' => IntegrationDsn::sqlitePath('locking')] : []; + config([ + 'database.connections.it_owner' => IntegrationDsn::connectionConfig($lane, $overrides), + 'database.connections.it_other' => IntegrationDsn::connectionConfig($lane, $overrides), + ]); + DB::setDefaultConnection('it_owner'); + Schema::dropIfExists('records'); + Schema::dropIfExists('versioned_records'); + Schema::create('records', function (Blueprint $table): void { + $table->increments('id'); + $table->string('status'); + $table->integer('amount'); + $table->string('email')->nullable(); + }); + Schema::create('versioned_records', function (Blueprint $table): void { + $table->increments('id'); + $table->string('name'); + $table->integer('version')->default(0); + }); + + return DB::connection('it_other'); +} + +function lockingWriteFromOther(Connection $other, string $lane): ?Throwable +{ + match ($lane) { + 'pgsql' => $other->statement("set lock_timeout = '300ms'"), + 'mysql', 'mariadb' => $other->statement('set session innodb_lock_wait_timeout = 1'), + 'sqlsrv' => $other->unprepared('set lock_timeout 300'), // a prepared SET is undone when sp_prepexec returns + default => null, + }; + + try { + $other->table('records')->where('id', 1)->update(['status' => 'stolen']); + } catch (Throwable $e) { + return (new PersistenceExceptionTranslator)->translate($e, $other->getDriverName()); + } + + return null; +} + +foreach (['pgsql', 'mysql', 'mariadb', 'sqlsrv'] as $lane) { + it("holds the row under PESSIMISTIC_WRITE and PESSIMISTIC_READ on {$lane}", function () use ($lane): void { + $other = lockingLane($lane); + Record::query()->create(['status' => 'open', 'amount' => 50, 'email' => 'a@x.test']); + $repository = integrationLockingRepository(); + + try { + DB::transaction(function () use ($repository, $other, $lane): void { + expect($repository->findByIdForUpdate(1)?->email)->toBe('a@x.test') + ->and(lockingWriteFromOther($other, $lane))->toBeInstanceOf(CannotAcquireLockException::class); + }); + DB::transaction(function () use ($repository, $other, $lane): void { + expect($repository->findByAmountBetween(1, 100))->toHaveCount(1) + ->and(lockingWriteFromOther($other, $lane))->toBeInstanceOf(CannotAcquireLockException::class); + }); + + // Outside the transaction the row is free again. + expect(lockingWriteFromOther($other, $lane))->toBeNull(); + } finally { + Schema::dropIfExists('records'); + Schema::dropIfExists('versioned_records'); + DB::connection('it_owner')->disconnect(); + $other->disconnect(); + } + })->group('integration')->skip(IntegrationDsn::skipReason($lane) !== null, (string) IntegrationDsn::skipReason($lane)); +} + +foreach (IntegrationDsn::lanes() as $lane) { + it("refuses a stale versioned save on {$lane}", function () use ($lane): void { + $other = lockingLane($lane); + + try { + $repository = new VersionedRecordRepository; + $id = $repository->save(new VersionedRecord(['name' => 'orig']))->id; + $first = $repository->findById($id); + $second = $repository->findById($id); + assert($first instanceof VersionedRecord && $second instanceof VersionedRecord); + + $first->name = 'first'; + $repository->save($first); + $second->name = 'second'; + + expect(fn () => $repository->save($second))->toThrow(OptimisticLockException::class) + ->and($repository->findById($id)?->name)->toBe('first') + ->and($repository->findById($id)?->version)->toEqual(1); + } finally { + Schema::dropIfExists('records'); + Schema::dropIfExists('versioned_records'); + DB::connection('it_owner')->disconnect(); + $other->disconnect(); + } + })->group('integration')->skip(IntegrationDsn::skipReason($lane) !== null, (string) IntegrationDsn::skipReason($lane)); +} diff --git a/packages/data/tests/Integration/StatementTimeoutApplierIntegrationTest.php b/packages/data/tests/Integration/StatementTimeoutApplierIntegrationTest.php new file mode 100644 index 00000000..68f88a33 --- /dev/null +++ b/packages/data/tests/Integration/StatementTimeoutApplierIntegrationTest.php @@ -0,0 +1,138 @@ +selectOne($sql); + + return is_object($row) ? (get_object_vars($row)['value'] ?? null) : null; +} + +function timeoutTranslated(Closure $statement, string $driver): ?Throwable +{ + try { + $statement(); + } catch (Throwable $e) { + return (new PersistenceExceptionTranslator)->translate($e, $driver); + } + + return null; +} + +it('interrupts a slow statement on pgsql with SET LOCAL statement_timeout', function () { + $connection = IntegrationDsn::connect('pgsql', 'timeout-pgsql'); + $connection->beginTransaction(); + $restore = (new StatementTimeoutApplier)->apply($connection, 1); + + $started = microtime(true); + $error = timeoutTranslated(static fn () => $connection->select('select pg_sleep(3)'), 'pgsql'); + + expect($error)->toBeInstanceOf(QueryTimeoutException::class) + ->and(microtime(true) - $started)->toBeLessThan(2.5); + $connection->rollBack(); + $restore(); + $connection->disconnect(); +})->group('integration')->skip(IntegrationDsn::skipReason('pgsql') !== null, (string) IntegrationDsn::skipReason('pgsql')); + +it('interrupts any statement past max_statement_time on mariadb, then restores it', function () { + $connection = IntegrationDsn::connect('mariadb', 'timeout-mariadb'); + $before = timeoutValue($connection, 'select @@session.max_statement_time as value'); + $connection->beginTransaction(); + $restore = (new StatementTimeoutApplier)->apply($connection, 1); + + $error = timeoutTranslated(static fn () => $connection->select('select sleep(3)'), 'mariadb'); + $connection->rollBack(); + $restore(); + + expect($error)->toBeInstanceOf(QueryTimeoutException::class) + ->and(timeoutValue($connection, 'select @@session.max_statement_time as value'))->toEqual($before); + $connection->disconnect(); +})->group('integration')->skip(IntegrationDsn::skipReason('mariadb') !== null, (string) IntegrationDsn::skipReason('mariadb')); + +it('bounds a read with max_execution_time on mysql, then restores both session variables', function () { + $connection = IntegrationDsn::connect('mysql', 'timeout-mysql'); + $executionBefore = timeoutValue($connection, 'select @@session.max_execution_time as value'); + $lockBefore = timeoutValue($connection, 'select @@session.innodb_lock_wait_timeout as value'); + $connection->beginTransaction(); + $restore = (new StatementTimeoutApplier)->apply($connection, 1); + + expect(timeoutValue($connection, 'select @@session.max_execution_time as value'))->toEqual(1000) + ->and(timeoutValue($connection, 'select @@session.innodb_lock_wait_timeout as value'))->toEqual(1); + + // MySQL interrupts SLEEP() inside a timed-out SELECT either with ER_QUERY_TIMEOUT (3024) or by returning 1; both + // end the statement inside the budget, which is the property that matters. + $started = microtime(true); + $error = timeoutTranslated(static fn () => $connection->select('select sleep(3) as slept'), 'mysql'); + expect(microtime(true) - $started)->toBeLessThan(2.5) + ->and($error === null || $error instanceof QueryTimeoutException)->toBeTrue(); + + $connection->rollBack(); + $restore(); + expect(timeoutValue($connection, 'select @@session.max_execution_time as value'))->toEqual($executionBefore) + ->and(timeoutValue($connection, 'select @@session.innodb_lock_wait_timeout as value'))->toEqual($lockBefore); + $connection->disconnect(); +})->group('integration')->skip(IntegrationDsn::skipReason('mysql') !== null, (string) IntegrationDsn::skipReason('mysql')); + +it('fails a lock wait past LOCK_TIMEOUT on sqlsrv, then restores the session value', function () { + $holder = IntegrationDsn::connect('sqlsrv', 'timeout-holder'); + $waiter = IntegrationDsn::connect('sqlsrv', 'timeout-waiter'); + $schema = $holder->getSchemaBuilder(); + $schema->dropIfExists('firefly_timeout_probe'); + $schema->create('firefly_timeout_probe', static function (Blueprint $table): void { + $table->integer('id')->primary(); + $table->string('name'); + }); + $holder->table('firefly_timeout_probe')->insert(['id' => 1, 'name' => 'a']); + + try { + $holder->beginTransaction(); + $holder->table('firefly_timeout_probe')->where('id', 1)->update(['name' => 'held']); + + $before = timeoutValue($waiter, 'select @@lock_timeout as value'); + $waiter->beginTransaction(); + $restore = (new StatementTimeoutApplier)->apply($waiter, 1); + $error = timeoutTranslated(static fn () => $waiter->table('firefly_timeout_probe')->where('id', 1)->update(['name' => 'waiter']), 'sqlsrv'); + $waiter->rollBack(); + $restore(); + + expect($error)->toBeInstanceOf(CannotAcquireLockException::class) + ->and(timeoutValue($waiter, 'select @@lock_timeout as value'))->toEqual($before); + } finally { + $holder->rollBack(); + $schema->dropIfExists('firefly_timeout_probe'); + $holder->disconnect(); + $waiter->disconnect(); + } +})->group('integration')->skip(IntegrationDsn::skipReason('sqlsrv') !== null, (string) IntegrationDsn::skipReason('sqlsrv')); + +it('sets and restores the busy timeout on a sqlite file without failing the unit of work', function () { + $path = IntegrationDsn::sqlitePath('timeout'); + $connection = IntegrationDsn::connect('sqlite', 'timeout-sqlite', ['database' => $path]); + $connection->beginTransaction(); + $restore = (new StatementTimeoutApplier)->apply($connection, 1); + + expect($connection->selectOne('select 1 as one'))->not->toBeNull(); + $connection->commit(); + $restore(); + $connection->disconnect(); + foreach ([$path, $path.'-wal', $path.'-shm'] as $file) { + if (is_file($file)) { + unlink($file); + } + } +})->group('integration'); diff --git a/packages/data/tests/Support/deadlock-racer.php b/packages/data/tests/Support/deadlock-racer.php new file mode 100644 index 00000000..c7338c5f --- /dev/null +++ b/packages/data/tests/Support/deadlock-racer.php @@ -0,0 +1,40 @@ +beginTransaction(); +$connection->table('firefly_deadlock_probe')->where('id', 2)->update(['name' => 'racer']); +fwrite(STDOUT, "locked\n"); +fflush(STDOUT); + +$deadline = microtime(true) + 20; +while (! is_file($go) && microtime(true) < $deadline) { + usleep(1000); +} + +try { + $connection->table('firefly_deadlock_probe')->where('id', 1)->update(['name' => 'racer']); + $connection->commit(); + fwrite(STDOUT, "committed\n"); +} catch (Throwable $e) { + $connection->rollBack(); + $translated = (new PersistenceExceptionTranslator)->translate($e, $connection->getDriverName()); + fwrite(STDOUT, ($translated instanceof DeadlockLoserDataAccessException ? 'deadlock' : 'other '.$translated::class)."\n"); +} + +$connection->disconnect(); diff --git a/packages/data/tests/Transaction/StatementTimeoutApplierTest.php b/packages/data/tests/Transaction/StatementTimeoutApplierTest.php index b6fe1778..2da3178e 100644 --- a/packages/data/tests/Transaction/StatementTimeoutApplierTest.php +++ b/packages/data/tests/Transaction/StatementTimeoutApplierTest.php @@ -9,6 +9,7 @@ use Illuminate\Database\MySqlConnection; use Illuminate\Database\PostgresConnection; use Illuminate\Database\SQLiteConnection; +use Illuminate\Database\SqlServerConnection; /** * A driver-grammar connection over an in-memory sqlite PDO: under pretend() nothing executes, so the exact @@ -168,3 +169,72 @@ public function statement($query, $bindings = []) expect($phases['applied'])->toBe([]) ->and($phases['restored'])->toBe([]); }); + +it('sets SQL Server\'s session LOCK_TIMEOUT in milliseconds, restoring nothing it did not read', function () { + $phases = applyAndRestore(pretendConnection(SqlServerConnection::class, 'sqlsrv'), 5); + + expect($phases['applied'])->toBe(['select @@lock_timeout as value', 'set lock_timeout 5000']) + ->and($phases['restored'])->toBe([]); +}); + +it('puts SQL Server\'s previous LOCK_TIMEOUT back — -1, wait forever, is the server default', function () { + $connection = new class(new PDO('sqlite::memory:'), 'pretend', '', ['driver' => 'sqlsrv', 'name' => 'pretend']) extends SqlServerConnection + { + /** + * @param string $query + * @param array $bindings + * @param bool $useReadPdo + */ + public function selectOne($query, $bindings = [], $useReadPdo = true) + { + parent::selectOne($query, $bindings, $useReadPdo); + + return str_contains($query, '@@lock_timeout') ? (object) ['value' => -1] : null; + } + }; + + $phases = applyAndRestore($connection, 2); + + expect($phases['applied'])->toBe(['select @@lock_timeout as value', 'set lock_timeout 2000']) + ->and($phases['restored'])->toBe(['set lock_timeout -1']); +}); + +it('sets SQL Server\'s LOCK_TIMEOUT as a direct batch, because a prepared SET does not outlive sp_prepexec', function () { + $connection = new class(new PDO('sqlite::memory:'), 'pretend', '', ['driver' => 'sqlsrv', 'name' => 'pretend']) extends SqlServerConnection + { + /** @var list */ + public array $direct = []; + + /** + * @param string $query + * @param array $bindings + * @param bool $useReadPdo + */ + public function selectOne($query, $bindings = [], $useReadPdo = true) + { + return (object) ['value' => -1]; + } + + /** + * @param string $query + * @param array $bindings + */ + public function statement($query, $bindings = []): bool + { + throw new LogicException("prepared statement used for [{$query}]"); + } + + /** @param string $query */ + public function unprepared($query): bool + { + $this->direct[] = $query; + + return true; + } + }; + + $restore = (new StatementTimeoutApplier)->apply($connection, 3); + $restore(); + + expect($connection->direct)->toBe(['set lock_timeout 3000', 'set lock_timeout -1']); +}); diff --git a/packages/eda-database/.gitattributes b/packages/eda-database/.gitattributes new file mode 100644 index 00000000..538b69a4 --- /dev/null +++ b/packages/eda-database/.gitattributes @@ -0,0 +1,2 @@ +/tests export-ignore +/.gitattributes export-ignore diff --git a/packages/eda-database/LICENSE b/packages/eda-database/LICENSE new file mode 100644 index 00000000..2240005d --- /dev/null +++ b/packages/eda-database/LICENSE @@ -0,0 +1,204 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +Copyright 2026 Firefly Software Solutions Inc. diff --git a/packages/eda-database/README.md b/packages/eda-database/README.md new file mode 100644 index 00000000..8d50d0ff --- /dev/null +++ b/packages/eda-database/README.md @@ -0,0 +1,12 @@ +# firefly/eda-database + +LaraFly's portable same-transaction outbox. With `firefly.eda.provider=database`, an `EventPublisher::publish()` — +and every domain event, through the pre-commit hook — writes a `firefly_eda_outbox` row on the aggregate's own +connection inside its open transaction, on PostgreSQL, MySQL, MariaDB, SQL Server or SQLite. `firefly:eda:consume` +delivers committed rows to `#[EventListener]` handlers and the optional `firefly:outbox:relay` forwards them to a +downstream broker. Rows are claimed by leases through an `OutboxClaimStrategy` (SKIP LOCKED, READPAST or a portable +optimistic lease, chosen from the server's capabilities), so any number of consumers and relays may run at once. +`firefly/eda-postgres` builds on this package and adds `pg_notify`/`LISTEN`. See +[docs/modules/eda-database.md](../../docs/modules/eda-database.md). + +Apache-2.0 © Firefly Software Solutions Inc. diff --git a/packages/eda-database/cache/firefly-eda-database-components.php b/packages/eda-database/cache/firefly-eda-database-components.php new file mode 100644 index 00000000..f1f50859 --- /dev/null +++ b/packages/eda-database/cache/firefly-eda-database-components.php @@ -0,0 +1,124 @@ + [ + 'class' => 'Firefly\\Eda\\Database\\DatabaseOutboxAutoConfiguration', + 'stereotype' => 'configuration', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 900, + 'qualifier' => null, + 'interfaces' => [ + ], + 'beans' => [ + 0 => [ + 'method' => 'subscriberRegistry', + 'returns' => 'Firefly\\Eda\\Bus\\SubscriberRegistry', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => false, + 'dependencies' => [ + ], + ], + 1 => [ + 'method' => 'eventPublisher', + 'returns' => 'Firefly\\Eda\\EventPublisher', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Config\\Config', + 1 => 'Illuminate\\Database\\ConnectionResolverInterface', + 2 => 'Firefly\\Eda\\Bus\\SubscriberRegistry', + 3 => 'Firefly\\Eda\\Tracing\\EdaTracing', + ], + ], + 2 => [ + 'method' => 'outboxPreCommitHook', + 'returns' => 'Firefly\\Eda\\Database\\Outbox\\OutboxPreCommitHook', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => false, + 'dependencies' => [ + 0 => 'Illuminate\\Database\\ConnectionResolverInterface', + 1 => 'Firefly\\Config\\Config', + 2 => 'Firefly\\Cqrs\\Handler\\HandlerManifest', + 3 => 'Firefly\\Cqrs\\Correlation\\CorrelationContext', + 4 => 'Firefly\\Eda\\Bus\\SubscriberRegistry', + 5 => 'Firefly\\Eda\\Tracing\\EdaTracing', + ], + ], + 3 => [ + 'method' => 'domainEventDispatcher', + 'returns' => 'Firefly\\Data\\Domain\\DomainEventDispatcher', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Data\\Domain\\AggregateTracker', + 1 => 'Firefly\\Context\\Event\\ApplicationEventPublisher', + 2 => 'Firefly\\Eda\\Database\\Outbox\\OutboxPreCommitHook', + ], + ], + 4 => [ + 'method' => 'commandEventPublisher', + 'returns' => 'Firefly\\Cqrs\\Event\\CommandEventPublisher', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => false, + 'dependencies' => [ + ], + ], + 5 => [ + 'method' => 'eventConsumer', + 'returns' => 'Firefly\\Eda\\Consumer\\EventConsumer', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Config\\Config', + 1 => 'Illuminate\\Database\\ConnectionResolverInterface', + ], + ], + ], + 'lazy' => false, + 'dependencies' => [ + ], + ], + 1 => [ + 'class' => 'Firefly\\Eda\\Database\\OutboxHealthIndicator', + 'stereotype' => 'component', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'qualifier' => null, + 'interfaces' => [ + 0 => 'Firefly\\Actuator\\Health\\HealthIndicator', + ], + 'beans' => [ + ], + 'lazy' => false, + 'dependencies' => [ + 0 => 'Illuminate\\Database\\ConnectionResolverInterface', + 1 => 'Firefly\\Config\\Config', + ], + ], +]; diff --git a/packages/eda-database/cache/firefly-eda-database-context.php b/packages/eda-database/cache/firefly-eda-database-context.php new file mode 100644 index 00000000..adf9214e --- /dev/null +++ b/packages/eda-database/cache/firefly-eda-database-context.php @@ -0,0 +1,120 @@ + [ + 'class' => 'Firefly\\Eda\\Database\\DatabaseOutboxAutoConfiguration', + 'postConstruct' => [ + ], + 'preDestroy' => [ + ], + 'listeners' => [ + ], + 'conditions' => [ + ], + 'beanConditions' => [ + 0 => [ + 'method' => 'subscriberRegistry', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.eda.provider', + 1 => 'database', + 2 => false, + ], + ], + ], + ], + 1 => [ + 'method' => 'eventPublisher', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.eda.provider', + 1 => 'database', + 2 => false, + ], + ], + ], + ], + 2 => [ + 'method' => 'outboxPreCommitHook', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.eda.provider', + 1 => 'database', + 2 => false, + ], + ], + ], + ], + 3 => [ + 'method' => 'domainEventDispatcher', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.eda.provider', + 1 => 'database', + 2 => false, + ], + ], + ], + ], + 4 => [ + 'method' => 'commandEventPublisher', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.eda.provider', + 1 => 'database', + 2 => false, + ], + ], + ], + ], + 5 => [ + 'method' => 'eventConsumer', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.eda.provider', + 1 => 'database', + 2 => false, + ], + ], + ], + ], + ], + ], + 1 => [ + 'class' => 'Firefly\\Eda\\Database\\OutboxHealthIndicator', + 'postConstruct' => [ + ], + 'preDestroy' => [ + ], + 'listeners' => [ + ], + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.eda.provider', + 1 => 'database', + 2 => false, + ], + ], + ], + 'beanConditions' => [ + ], + ], +]; diff --git a/packages/eda-database/composer.json b/packages/eda-database/composer.json new file mode 100644 index 00000000..3f22da00 --- /dev/null +++ b/packages/eda-database/composer.json @@ -0,0 +1,65 @@ +{ + "name": "firefly/eda-database", + "description": "LaraFly eda database adapter: a GENUINE same-transaction firefly_eda_outbox on any Laravel database connection (PostgreSQL, MySQL, MariaDB, SQL Server, SQLite), claimed by leases through an OutboxClaimStrategy port (SKIP LOCKED, READPAST or a portable optimistic lease, chosen from the server's capabilities) so any number of consumers and relays can run concurrently; an in-process consumer for #[EventListener] handlers and an OPTIONAL firefly:outbox:relay to a downstream broker. Auto-configured behind firefly.eda.provider=database.", + "type": "library", + "license": "Apache-2.0", + "homepage": "https://github.com/fireflyframework/fireflyframework-php", + "authors": [ + { + "name": "Firefly Software Solutions Inc.", + "homepage": "https://github.com/fireflyframework" + } + ], + "keywords": [ + "firefly", + "laravel", + "outbox", + "database" + ], + "support": { + "issues": "https://github.com/fireflyframework/fireflyframework-php/issues", + "source": "https://github.com/fireflyframework/fireflyframework-php/tree/main/packages/eda-database" + }, + "require": { + "php": "^8.3", + "firefly/actuator": "*@dev", + "firefly/autoconfigure": "*@dev", + "firefly/config": "*@dev", + "firefly/container": "*@dev", + "firefly/context": "*@dev", + "firefly/cqrs": "*@dev", + "firefly/data": "*@dev", + "firefly/domain": "*@dev", + "firefly/eda": "*@dev", + "firefly/kernel": "*@dev", + "illuminate/console": "^13.0", + "illuminate/contracts": "^13.0", + "illuminate/database": "^13.0", + "illuminate/support": "^13.0" + }, + "extra": { + "laravel": { + "providers": [ + "Firefly\\Eda\\Database\\EdaDatabaseServiceProvider", + "Firefly\\Eda\\Database\\EdaDatabaseBootServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "26.x-dev" + } + }, + "autoload": { + "psr-4": { + "Firefly\\Eda\\Database\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Firefly\\Eda\\Database\\Tests\\": "tests/" + } + }, + "minimum-stability": "stable", + "config": { + "sort-packages": true + } +} diff --git a/packages/eda-postgres/database/migrations/2026_07_27_000000_create_firefly_eda_outbox_table.php b/packages/eda-database/database/migrations/2026_07_27_000000_create_firefly_eda_outbox_table.php similarity index 90% rename from packages/eda-postgres/database/migrations/2026_07_27_000000_create_firefly_eda_outbox_table.php rename to packages/eda-database/database/migrations/2026_07_27_000000_create_firefly_eda_outbox_table.php index c11c7cd2..e0e144fe 100644 --- a/packages/eda-postgres/database/migrations/2026_07_27_000000_create_firefly_eda_outbox_table.php +++ b/packages/eda-database/database/migrations/2026_07_27_000000_create_firefly_eda_outbox_table.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use Firefly\Eda\Postgres\Outbox\OutboxSchema; +use Firefly\Eda\Database\Outbox\OutboxSchema; use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; diff --git a/packages/eda-database/database/migrations/2026_10_12_000000_add_claim_columns_to_firefly_eda_outbox_table.php b/packages/eda-database/database/migrations/2026_10_12_000000_add_claim_columns_to_firefly_eda_outbox_table.php new file mode 100644 index 00000000..50e1826d --- /dev/null +++ b/packages/eda-database/database/migrations/2026_10_12_000000_add_claim_columns_to_firefly_eda_outbox_table.php @@ -0,0 +1,36 @@ + OutboxSchema::claimColumns($table)); + } + + public function down(): void + { + if (! Schema::hasTable(OutboxSchema::TABLE) || ! Schema::hasColumn(OutboxSchema::TABLE, 'claimed_by')) { + return; + } + + Schema::table(OutboxSchema::TABLE, function (Blueprint $table): void { + $table->dropIndex(OutboxSchema::CLAIM_INDEX); + $table->dropColumn(['claimed_by', 'claimed_until']); + }); + } +}; diff --git a/packages/eda-database/src/Console/OutboxRelayCommand.php b/packages/eda-database/src/Console/OutboxRelayCommand.php new file mode 100644 index 00000000..d263f3fa --- /dev/null +++ b/packages/eda-database/src/Console/OutboxRelayCommand.php @@ -0,0 +1,103 @@ + publish -> mark PUBLISHED/FAILED).'; + + public function handle(ConnectionResolverInterface $connections, Container $container, Config $config): int + { + try { + $downstream = RelayDownstream::resolve($container, $config); + $settings = DatabaseOutboxSettings::from($config); + + $relay = new OutboxRelay( + $settings->connectionFrom($connections), + $downstream, + OutboxClaimStrategies::configured($settings->claimStrategy), + (int) $this->option('batch-size'), + $settings->maxAttempts, + // The UNGATED seam: it CONTINUES the traceparent the claimed row carries; RelayDownstream already gated + // the downstream publisher on firefly.eda.tracing.brokers.enabled. + BrokerTracing::bound($container), + $settings->leaseSeconds, + ); + + return $this->drain($relay); + } catch (ConfigurationException $e) { + $this->error($e->getMessage()); + + return self::FAILURE; + } + } + + private function drain(OutboxRelay $relay): int + { + $maxMessages = $this->option('max-messages'); + $timeLimit = $this->option('time-limit'); + $deadline = is_numeric($timeLimit) ? time() + (int) $timeLimit : null; + $sleep = max(0, (int) $this->option('sleep')); + $published = 0; + $stop = false; + + if (function_exists('pcntl_async_signals')) { + pcntl_async_signals(true); + pcntl_signal(SIGINT, function () use (&$stop): void { + $stop = true; + }); + pcntl_signal(SIGTERM, function () use (&$stop): void { + $stop = true; + }); + } + + while (true) { + $n = $relay->relayBatch(); + $published += $n; + + if (($maxMessages !== null && is_numeric($maxMessages) && $published >= (int) $maxMessages) + || ($deadline !== null && time() >= $deadline) + || $stop) { + break; + } + if ($n === 0) { + sleep($sleep); + } + } + + $this->info("firefly:outbox:relay — published {$published} row(s)."); + + return self::SUCCESS; + } +} diff --git a/packages/eda-database/src/DatabaseEventConsumer.php b/packages/eda-database/src/DatabaseEventConsumer.php new file mode 100644 index 00000000..18f93118 --- /dev/null +++ b/packages/eda-database/src/DatabaseEventConsumer.php @@ -0,0 +1,164 @@ +claim = $claim ?? OutboxClaimStrategies::configured(OutboxClaimStrategies::AUTO); + $this->claimant = $claimant ?? LeaseClock::claimant(); + $this->awaitWork = $awaitWork ?? function (int $timeoutMs): void { + usleep(max(0, min($timeoutMs, $this->pollIntervalMs)) * 1000); + }; + $this->clock = $clock; + } + + public function claimant(): string + { + return $this->claimant; + } + + /** Destinations are irrelevant here: the consumer claims every PENDING row of the outbox. */ + public function subscribe(array $destinations): void {} + + public function start(): void {} + + public function poll(int $timeoutMs): ?ReceivedEnvelope + { + $received = $this->claimOne(); + if ($received !== null) { + return $received; + } + + try { + ($this->awaitWork)($timeoutMs); + } catch (Throwable) { + // The wait is an optimization; the claim below delivers regardless. + } + + return $this->claimOne(); + } + + public function ack(ReceivedEnvelope $received): void + { + ClaimableRows::owned($this->connection, OutboxRow::asInt($received->deliveryTag), $this->claimant)->update([ + 'status' => OutboxSchema::STATUS_PUBLISHED, + 'processed_at' => $this->connection->raw('CURRENT_TIMESTAMP'), + 'claimed_by' => null, + 'claimed_until' => null, + ]); + } + + /** + * A handler failure (requeue: true) counts an attempt and frees the row for an immediate retry, or FAILS it past + * maxAttempts; a poison record (requeue: false) FAILS at once — retrying bytes nobody can decode changes nothing. + */ + public function nack(ReceivedEnvelope $received, bool $requeue = true): void + { + $id = OutboxRow::asInt($received->deliveryTag); + $row = ClaimableRows::owned($this->connection, $id, $this->claimant)->first(); + if ($row === null) { + return; // the lease is gone: the row is another claimant's now + } + + $attempts = OutboxRow::asInt($row->attempts) + 1; + $failed = ! $requeue || $attempts >= $this->maxAttempts; + + ClaimableRows::owned($this->connection, $id, $this->claimant)->update([ + 'attempts' => $attempts, + 'status' => $failed ? OutboxSchema::STATUS_FAILED : OutboxSchema::STATUS_PENDING, + 'error_message' => $received->isPoison() + ? 'undecodable outbox row: '.($received->failure?->getMessage() ?? 'unknown') + : 'in-process handler failed', + 'failed_at' => $failed ? $this->connection->raw('CURRENT_TIMESTAMP') : null, + 'claimed_by' => null, + 'claimed_until' => null, + ]); + } + + public function stop(): void {} + + private function claimOne(): ?ReceivedEnvelope + { + $claim = OutboxClaim::at($this->claimant, 1, LeaseClock::now($this->clock), $this->leaseSeconds); + $row = ClaimableRows::load($this->connection, $this->claim->claim($this->connection, $claim), $this->claimant)->first(); + + if ($row === null) { + return null; + } + + $id = OutboxRow::asInt($row->id); + + try { + return new ReceivedEnvelope(new EventEnvelope( + OutboxRow::asString($row->event_type), + OutboxRow::asString($row->destination), + OutboxRow::asMap($row->payload), + OutboxRow::asStringMap($row->headers), + ), $id); + } catch (JsonException $e) { + return ReceivedEnvelope::poison(OutboxRow::asString($row->payload), $id, $e, OutboxRow::asString($row->destination)); + } + } +} diff --git a/packages/eda-database/src/DatabaseEventPublisher.php b/packages/eda-database/src/DatabaseEventPublisher.php new file mode 100644 index 00000000..82bf147f --- /dev/null +++ b/packages/eda-database/src/DatabaseEventPublisher.php @@ -0,0 +1,99 @@ +tracing = $tracing ?? new NoOpEdaTracing; + } + + /** + * Records the pattern on the SHARED SubscriberRegistry — the same singleton the outbox auto-configuration hands to + * this writer and that firefly:eda:consume feeds every claimed envelope into. Delivery is the consumer's job. + */ + public function subscribe(string $eventTypePattern, callable $handler): void + { + $this->registry->subscribe($eventTypePattern, $handler); + } + + /** + * @param array $payload + * @param array $headers + */ + public function publish(string $destination, string $eventType, array $payload, array $headers = []): void + { + $this->tracing->tracePublish($destination, $eventType, $headers, function (array $headers) use ($destination, $eventType, $payload): void { + $id = $this->connection->table(OutboxSchema::TABLE)->insertGetId([ + 'destination' => $destination, + 'channel' => $this->channel, + 'event_type' => $eventType, + 'payload' => json_encode($payload, JSON_THROW_ON_ERROR), + 'headers' => json_encode($headers === [] ? new stdClass : $headers, JSON_THROW_ON_ERROR), + 'transaction_id' => $headers['x-correlation-id'] ?? null, + 'status' => OutboxSchema::STATUS_PENDING, + 'attempts' => 0, + 'created_at' => $this->connection->raw('CURRENT_TIMESTAMP'), + ]); + + $this->afterInsert($id); + }); + } + + /** + * Runs inside the traced closure, on the same connection — so inside the same transaction — as the INSERT. A no-op + * here; PostgresEventPublisher overrides it with the in-transaction pg_notify. + */ + protected function afterInsert(int $id): void {} + + public function start(): void {} + + public function stop(): void {} +} diff --git a/packages/eda-database/src/DatabaseOutboxAutoConfiguration.php b/packages/eda-database/src/DatabaseOutboxAutoConfiguration.php new file mode 100644 index 00000000..43e70722 --- /dev/null +++ b/packages/eda-database/src/DatabaseOutboxAutoConfiguration.php @@ -0,0 +1,112 @@ + DatabaseEventPublisher (the outbox writer, and the object + * EventListenerWiringPass subscribes listeners on — into the shared SubscriberRegistry below); + * - outboxPreCommitHook() + domainEventDispatcher() bind OUR DomainEventDispatcher carrying the hook, winning + * DataAutoConfiguration's #[ConditionalOnMissingBean(DomainEventDispatcher::class)]; + * - commandEventPublisher() NoOps the AFTER-commit eda leg, winning CqrsAutoConfiguration's + * #[ConditionalOnMissingBean(CommandEventPublisher::class)] — the double-publish avoidance: a domain event is + * written EXACTLY ONCE, in the transaction, by the hook; + * - eventConsumer() binds the leased DatabaseEventConsumer firefly:eda:consume drives. + * Every bean is gated on provider=database, so with any other provider this class is inert — and + * PostgresOutboxAutoConfiguration does the same for provider=postgres from the same building blocks. + * + * Nothing here opens a connection: the claim strategy resolves at the first claim (ConfiguredClaimStrategy). + */ +#[Configuration] +#[Order(900)] +final class DatabaseOutboxAutoConfiguration +{ + #[Bean] + #[ConditionalOnProperty(name: 'firefly.eda.provider', havingValue: 'database')] + public function subscriberRegistry(): SubscriberRegistry + { + return new SubscriberRegistry; + } + + #[Bean] + #[ConditionalOnProperty(name: 'firefly.eda.provider', havingValue: 'database')] + public function eventPublisher(Config $config, ConnectionResolverInterface $connections, SubscriberRegistry $registry, ?EdaTracing $tracing = null): EventPublisher + { + $settings = DatabaseOutboxSettings::from($config); + + return new DatabaseEventPublisher( + $connections->connection($settings->connection), + $registry, + $settings->channel, + BrokerTracing::resolve($config, $tracing), + ); + } + + #[Bean] + #[ConditionalOnProperty(name: 'firefly.eda.provider', havingValue: 'database')] + public function outboxPreCommitHook(ConnectionResolverInterface $connections, Config $config, HandlerManifest $manifest, CorrelationContext $correlation, SubscriberRegistry $registry, ?EdaTracing $tracing = null): OutboxPreCommitHook + { + return new OutboxPreCommitHook( + $connections, + $registry, + DatabaseOutboxSettings::from($config)->channel, + $config->string('firefly.cqrs.default_destination', 'cqrs.events'), + $manifest->destinations(), + $correlation, + BrokerTracing::resolve($config, $tracing), + ); + } + + #[Bean] + #[ConditionalOnProperty(name: 'firefly.eda.provider', havingValue: 'database')] + public function domainEventDispatcher(AggregateTracker $tracker, ApplicationEventPublisher $publisher, OutboxPreCommitHook $hook): DomainEventDispatcher + { + return new DomainEventDispatcher($tracker, $publisher, $hook); + } + + #[Bean] + #[ConditionalOnProperty(name: 'firefly.eda.provider', havingValue: 'database')] + public function commandEventPublisher(): CommandEventPublisher + { + return new NoOpEventPublisher; + } + + #[Bean] + #[ConditionalOnProperty(name: 'firefly.eda.provider', havingValue: 'database')] + public function eventConsumer(Config $config, ConnectionResolverInterface $connections): EventConsumer + { + $settings = DatabaseOutboxSettings::from($config); + + return new DatabaseEventConsumer( + $settings->connectionFrom($connections), + OutboxClaimStrategies::configured($settings->claimStrategy), + $settings->maxAttempts, + $settings->leaseSeconds, + $settings->pollIntervalMs, + ); + } +} diff --git a/packages/eda-database/src/DatabaseOutboxSettings.php b/packages/eda-database/src/DatabaseOutboxSettings.php new file mode 100644 index 00000000..6083cd56 --- /dev/null +++ b/packages/eda-database/src/DatabaseOutboxSettings.php @@ -0,0 +1,90 @@ +string($connectionKey); + + $channelKey = self::firstSetKey($config, 'firefly.eda.database.channel', 'firefly.eda.postgres.channel'); + $channel = $channelKey === null ? 'firefly_eda_events' : $config->string($channelKey); + + $maxAttemptsKey = self::firstSetKey($config, 'firefly.eda.database.max-attempts', 'firefly.eda.postgres.max_attempts'); + $maxAttempts = $maxAttemptsKey === null ? 3 : $config->int($maxAttemptsKey); + + return new self( + connection: $connection === '' ? null : $connection, + channel: $channel, + maxAttempts: max(1, $maxAttempts), + claimStrategy: $config->string('firefly.eda.database.claim-strategy', 'auto'), + leaseSeconds: (float) max(1, $config->int('firefly.eda.database.lease-seconds', 300)), + pollIntervalMs: max(10, $config->int('firefly.eda.database.poll-interval-ms', 500)), + ); + } + + /** + * The first of $keys whose value is set AND not null, or null when none is: the alias rule every outbox key + * follows, shared with RelayDownstream so the relay's downstream key cannot drift from it. A null value is an + * unset env() and must fall through to the alias, which Config::has() alone would not do. + * + * @internal + */ + public static function firstSetKey(Config $config, string ...$keys): ?string + { + foreach ($keys as $key) { + if ($config->get($key) !== null) { + return $key; + } + } + + return null; + } + + /** The outbox connection as the concrete Connection the claim needs (driver name, PDO, transactions). */ + public function connectionFrom(ConnectionResolverInterface $connections): Connection + { + $connection = $connections->connection($this->connection); + + return $connection instanceof Connection ? $connection : throw self::notConcrete($this->connection, $connection); + } + + private static function notConcrete(?string $name, ConnectionInterface $connection): ConfigurationException + { + return new ConfigurationException(sprintf( + 'The outbox connection "%s" resolved to %s; the outbox needs an Illuminate\Database\Connection (it reads the ' + .'driver and the server version to choose its claim).', + $name ?? 'default', + get_debug_type($connection), + )); + } +} diff --git a/packages/eda-database/src/EdaDatabaseBootServiceProvider.php b/packages/eda-database/src/EdaDatabaseBootServiceProvider.php new file mode 100644 index 00000000..d3f07353 --- /dev/null +++ b/packages/eda-database/src/EdaDatabaseBootServiceProvider.php @@ -0,0 +1,31 @@ +app->make('config')->get('firefly.eda.provider') !== 'database') { + return; + } + + $this->loadMigrationsFrom(self::MIGRATIONS); + + if ($this->app->runningInConsole()) { + $this->commands([OutboxRelayCommand::class]); + } + } +} diff --git a/packages/eda-database/src/EdaDatabaseServiceProvider.php b/packages/eda-database/src/EdaDatabaseServiceProvider.php new file mode 100644 index 00000000..405ed7e5 --- /dev/null +++ b/packages/eda-database/src/EdaDatabaseServiceProvider.php @@ -0,0 +1,25 @@ +table(OutboxSchema::TABLE) + ->where('status', OutboxSchema::STATUS_PENDING) + ->where(static function (Builder $query) use ($claim): void { + $query->whereNull('claimed_until')->orWhere('claimed_until', '<', $claim->nowString()); + }); + } + + public static function candidates(Connection $connection, OutboxClaim $claim): Builder + { + return self::query($connection, $claim)->select('id')->orderBy('id')->limit($claim->limit); + } + + /** + * @return list + */ + public static function ids(Builder $query): array + { + $ids = []; + foreach ($query->pluck('id') as $id) { + $ids[] = OutboxRow::asInt($id); + } + + return $ids; + } + + /** + * @return array{claimed_by: string, claimed_until: string} + */ + public static function leaseColumns(OutboxClaim $claim): array + { + return ['claimed_by' => $claim->claimant, 'claimed_until' => $claim->untilString()]; + } + + /** + * @param list $ids + */ + public static function lease(Connection $connection, array $ids, OutboxClaim $claim): void + { + if ($ids === []) { + return; + } + + $connection->table(OutboxSchema::TABLE)->whereIn('id', $ids)->update(self::leaseColumns($claim)); + } + + /** + * @param list $ids + * @return Collection + */ + public static function load(Connection $connection, array $ids, string $claimant): Collection + { + if ($ids === []) { + /** @var Collection $none */ + $none = new Collection; + + return $none; + } + + /** @var Collection $rows */ + $rows = $connection->table(OutboxSchema::TABLE) + ->whereIn('id', $ids) + ->where('claimed_by', $claimant) + ->orderBy('id') + ->get(); + + return $rows; + } + + public static function owned(ConnectionInterface $connection, int $id, string $claimant): Builder + { + return $connection->table(OutboxSchema::TABLE) + ->where('id', $id) + ->where('status', OutboxSchema::STATUS_PENDING) + ->where('claimed_by', $claimant); + } +} diff --git a/packages/eda-database/src/Outbox/Claim/ConfiguredClaimStrategy.php b/packages/eda-database/src/Outbox/Claim/ConfiguredClaimStrategy.php new file mode 100644 index 00000000..d61b4755 --- /dev/null +++ b/packages/eda-database/src/Outbox/Claim/ConfiguredClaimStrategy.php @@ -0,0 +1,41 @@ + connection name => resolved strategy */ + private array $resolved = []; + + private ?OutboxClaimStrategy $last = null; + + public function __construct(private readonly string $configured = OutboxClaimStrategies::AUTO) {} + + public function name(): string + { + return $this->last?->name() ?? $this->configured; + } + + public function claim(Connection $connection, OutboxClaim $claim): array + { + return $this->strategyFor($connection)->claim($connection, $claim); + } + + public function strategyFor(Connection $connection): OutboxClaimStrategy + { + $key = (string) $connection->getName(); + + return $this->last = $this->resolved[$key] ??= OutboxClaimStrategies::resolve($connection, $this->configured); + } +} diff --git a/packages/eda-database/src/Outbox/Claim/OptimisticLeaseClaim.php b/packages/eda-database/src/Outbox/Claim/OptimisticLeaseClaim.php new file mode 100644 index 00000000..a0d4588f --- /dev/null +++ b/packages/eda-database/src/Outbox/Claim/OptimisticLeaseClaim.php @@ -0,0 +1,40 @@ +where('id', $id)->update(ClaimableRows::leaseColumns($claim)); + + if ($changed === 1) { + $won[] = $id; + } + } + + return $won; + } +} diff --git a/packages/eda-database/src/Outbox/Claim/OutboxClaim.php b/packages/eda-database/src/Outbox/Claim/OutboxClaim.php new file mode 100644 index 00000000..be4240f7 --- /dev/null +++ b/packages/eda-database/src/Outbox/Claim/OutboxClaim.php @@ -0,0 +1,34 @@ +now); + } + + public function untilString(): string + { + return LeaseClock::format($this->until); + } +} diff --git a/packages/eda-database/src/Outbox/Claim/OutboxClaimStrategies.php b/packages/eda-database/src/Outbox/Claim/OutboxClaimStrategies.php new file mode 100644 index 00000000..f099489e --- /dev/null +++ b/packages/eda-database/src/Outbox/Claim/OutboxClaimStrategies.php @@ -0,0 +1,62 @@ + */ + public const array NAMES = [self::AUTO, SkipLockedClaim::NAME, ReadPastClaim::NAME, OptimisticLeaseClaim::NAME]; + + /** The strategy for a configured name, resolved at the first claim (no connection is opened here). */ + public static function configured(string $name): OutboxClaimStrategy + { + return $name === OptimisticLeaseClaim::NAME ? new OptimisticLeaseClaim : new ConfiguredClaimStrategy($name); + } + + public static function resolve(Connection $connection, string $name = self::AUTO): OutboxClaimStrategy + { + return self::forCapabilities(DatabaseCapabilities::of($connection), $name); + } + + public static function forCapabilities(DatabaseCapabilities $capabilities, string $name = self::AUTO): OutboxClaimStrategy + { + return match ($name) { + self::AUTO => match (true) { + $capabilities->supportsSkipLocked() => new SkipLockedClaim, + $capabilities->supportsReadPast() => new ReadPastClaim, + default => new OptimisticLeaseClaim, + }, + SkipLockedClaim::NAME => $capabilities->supportsSkipLocked() ? new SkipLockedClaim : throw self::unsupported($name, $capabilities), + ReadPastClaim::NAME => $capabilities->supportsReadPast() ? new ReadPastClaim : throw self::unsupported($name, $capabilities), + OptimisticLeaseClaim::NAME => new OptimisticLeaseClaim, + default => throw new ConfigurationException(sprintf( + 'firefly.eda.database.claim-strategy="%s" is not one of [%s].', + $name, + implode('|', self::NAMES), + )), + }; + } + + private static function unsupported(string $name, DatabaseCapabilities $capabilities): ConfigurationException + { + return new ConfigurationException(sprintf( + 'firefly.eda.database.claim-strategy="%s" but %s does not support it (SKIP LOCKED needs PostgreSQL, MySQL ' + .'8.0.1+ or MariaDB 10.6+; READPAST needs SQL Server). Use "auto" or "optimistic-lease".', + $name, + $capabilities->describe(), + )); + } +} diff --git a/packages/eda-database/src/Outbox/Claim/OutboxClaimStrategy.php b/packages/eda-database/src/Outbox/Claim/OutboxClaimStrategy.php new file mode 100644 index 00000000..09b29864 --- /dev/null +++ b/packages/eda-database/src/Outbox/Claim/OutboxClaimStrategy.php @@ -0,0 +1,33 @@ +claimant` and `claimed_until = $claim->until`, committed before the caller does any work with + * them — and differs only in how it picks the rows without two claimants picking the same one: + * + * - SkipLockedClaim `FOR UPDATE SKIP LOCKED` inside a short transaction (PostgreSQL, MySQL 8.0.1+, MariaDB 10.6+); + * - ReadPastClaim `WITH (UPDLOCK, READPAST, ROWLOCK)` inside a short transaction (SQL Server); + * - OptimisticLeaseClaim an unlocked read, then one conditional UPDATE per row; the row is won when exactly one row + * changed (any driver — the portable default for SQLite and anything unknown). + * + * The LEASE is the guarantee, not the row lock: a lock dies with the claim transaction, so it cannot protect a + * handler that runs after it. A row is claimable while it is PENDING and its lease is empty or over; a claimant that + * crashes leaves its rows to the next claimant once their lease ends. + */ +interface OutboxClaimStrategy +{ + public function name(): string; + + /** + * Lease up to $claim->limit claimable rows to $claim->claimant, in a unit of work of its own. + * + * @return list the ids won, ascending + */ + public function claim(Connection $connection, OutboxClaim $claim): array; +} diff --git a/packages/eda-database/src/Outbox/Claim/ReadPastClaim.php b/packages/eda-database/src/Outbox/Claim/ReadPastClaim.php new file mode 100644 index 00000000..9954d923 --- /dev/null +++ b/packages/eda-database/src/Outbox/Claim/ReadPastClaim.php @@ -0,0 +1,35 @@ + $ids */ + $ids = $connection->transaction(static function () use ($connection, $claim): array { + $ids = ClaimableRows::ids(ClaimableRows::candidates($connection, $claim)->lock('with (updlock, readpast, rowlock)')); + ClaimableRows::lease($connection, $ids, $claim); + + return $ids; + }); + + return $ids; + } +} diff --git a/packages/eda-database/src/Outbox/Claim/SkipLockedClaim.php b/packages/eda-database/src/Outbox/Claim/SkipLockedClaim.php new file mode 100644 index 00000000..2aaa6c67 --- /dev/null +++ b/packages/eda-database/src/Outbox/Claim/SkipLockedClaim.php @@ -0,0 +1,41 @@ +transactionLevel() === 0 && in_array($connection->getDriverName(), ['mysql', 'mariadb'], true)) { + $connection->statement('set transaction isolation level read committed'); + } + + /** @var list $ids */ + $ids = $connection->transaction(static function () use ($connection, $claim): array { + $ids = ClaimableRows::ids(ClaimableRows::candidates($connection, $claim)->lock('for update skip locked')); + ClaimableRows::lease($connection, $ids, $claim); + + return $ids; + }); + + return $ids; + } +} diff --git a/packages/eda-database/src/Outbox/LeaseClock.php b/packages/eda-database/src/Outbox/LeaseClock.php new file mode 100644 index 00000000..435240dd --- /dev/null +++ b/packages/eda-database/src/Outbox/LeaseClock.php @@ -0,0 +1,52 @@ +setTimezone(new DateTimeZone('UTC')); + } + + public static function plus(DateTimeImmutable $instant, float $seconds): DateTimeImmutable + { + $milliseconds = max(0, (int) round($seconds * 1000)); + $interval = new DateInterval('PT'.intdiv($milliseconds, 1000).'S'); + $interval->f = ($milliseconds % 1000) / 1000; + + return $instant->add($interval); + } + + public static function format(DateTimeImmutable $instant): string + { + return $instant->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s.v'); + } + + /** `host:pid:random` — tells two processes on one node, and two claimants in one process, apart. */ + public static function claimant(): string + { + $host = gethostname(); + $pid = getmypid(); + + return substr(is_string($host) && $host !== '' ? $host : 'host', 0, 80).':'.($pid === false ? 0 : $pid).':'.bin2hex(random_bytes(4)); + } +} diff --git a/packages/eda-database/src/Outbox/OutboxPreCommitHook.php b/packages/eda-database/src/Outbox/OutboxPreCommitHook.php new file mode 100644 index 00000000..f14f7bf9 --- /dev/null +++ b/packages/eda-database/src/Outbox/OutboxPreCommitHook.php @@ -0,0 +1,62 @@ + $destinations eventClass => destination */ + public function __construct( + protected readonly ConnectionResolverInterface $connections, + protected readonly SubscriberRegistry $registry, + protected readonly string $channel, + protected readonly string $defaultDestination, + protected readonly array $destinations, + protected readonly ?CorrelationContext $correlation = null, + protected readonly ?EdaTracing $tracing = null, + ) {} + + public function handle(object $event, ?string $connection = null): void + { + if (! $event instanceof DomainEvent) { + return; + } + + // The aggregate's connection — the one carrying the open transaction. + $publisher = $this->writerFor($this->connections->connection($connection)); + + (new EdaCommandEventPublisher($publisher, $this->defaultDestination, $this->destinations, $this->correlation)) + ->publish($event); + } + + /** The writer for one event, on the aggregate's own connection. */ + protected function writerFor(ConnectionInterface $connection): DatabaseEventPublisher + { + return new DatabaseEventPublisher($connection, $this->registry, $this->channel, $this->tracing); + } +} diff --git a/packages/eda-database/src/Outbox/OutboxRelay.php b/packages/eda-database/src/Outbox/OutboxRelay.php new file mode 100644 index 00000000..db0c51a5 --- /dev/null +++ b/packages/eda-database/src/Outbox/OutboxRelay.php @@ -0,0 +1,130 @@ +tracing = $tracing ?? new NoOpEdaTracing; + $this->claim = $claim ?? OutboxClaimStrategies::configured(OutboxClaimStrategies::AUTO); + $this->claimant = $claimant ?? LeaseClock::claimant(); + $this->clock = $clock; + } + + public function claimant(): string + { + return $this->claimant; + } + + public function relayBatch(): int + { + $claim = OutboxClaim::at($this->claimant, $this->batchSize, LeaseClock::now($this->clock), $this->leaseSeconds); + $relayed = 0; + + foreach (ClaimableRows::load($this->connection, $this->claim->claim($this->connection, $claim), $this->claimant) as $row) { + $id = OutboxRow::asInt($row->id); + + try { + // The envelope is rebuilt ONLY so the seam has a traceparent to continue and a message id to name the + // span with — the downstream still receives the row's own four columns, byte for byte. + $this->tracing->traceConsume( + new EventEnvelope( + OutboxRow::asString($row->event_type), + OutboxRow::asString($row->destination), + OutboxRow::asMap($row->payload), + OutboxRow::asStringMap($row->headers), + OutboxRow::asString($row->id), + ), + fn (EventEnvelope $claimed) => $this->downstream->publish( + $claimed->destination, + $claimed->eventType, + $claimed->payload, + $claimed->headers, + ), + ); + + $relayed += ClaimableRows::owned($this->connection, $id, $this->claimant)->update([ + 'status' => OutboxSchema::STATUS_PUBLISHED, + 'processed_at' => $this->connection->raw('CURRENT_TIMESTAMP'), + 'claimed_by' => null, + 'claimed_until' => null, + ]); + } catch (Throwable $e) { + $attempts = OutboxRow::asInt($row->attempts) + 1; + $failed = $attempts >= $this->maxAttempts; + + ClaimableRows::owned($this->connection, $id, $this->claimant)->update([ + 'attempts' => $attempts, + 'status' => $failed ? OutboxSchema::STATUS_FAILED : OutboxSchema::STATUS_PENDING, + 'error_message' => $e->getMessage(), + 'failed_at' => $failed ? $this->connection->raw('CURRENT_TIMESTAMP') : null, + 'claimed_by' => null, + 'claimed_until' => null, + ]); + } + } + + return $relayed; + } +} diff --git a/packages/eda-database/src/Outbox/OutboxRow.php b/packages/eda-database/src/Outbox/OutboxRow.php new file mode 100644 index 00000000..d22565d4 --- /dev/null +++ b/packages/eda-database/src/Outbox/OutboxRow.php @@ -0,0 +1,58 @@ + */ + public static function asMap(mixed $json): array + { + if (! is_string($json)) { + return []; + } + + $decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + if (! is_array($decoded)) { + return []; + } + + $map = []; + foreach ($decoded as $key => $value) { + $map[(string) $key] = $value; + } + + return $map; + } + + /** @return array */ + public static function asStringMap(mixed $json): array + { + $map = []; + foreach (self::asMap($json) as $key => $value) { + if (is_scalar($value)) { + $map[$key] = (string) $value; + } + } + + return $map; + } +} diff --git a/packages/eda-database/src/Outbox/OutboxSchema.php b/packages/eda-database/src/Outbox/OutboxSchema.php new file mode 100644 index 00000000..41d971ba --- /dev/null +++ b/packages/eda-database/src/Outbox/OutboxSchema.php @@ -0,0 +1,60 @@ +bigIncrements('id'); + $table->string('destination'); + $table->string('channel', 63); + $table->string('event_type'); + $table->json('payload'); + $table->json('headers'); + $table->string('transaction_id')->nullable(); + $table->string('status')->default(self::STATUS_PENDING); + $table->unsignedInteger('attempts')->default(0); + $table->text('error_message')->nullable(); + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('processed_at')->nullable(); + $table->timestamp('failed_at')->nullable(); + + $table->index(['status', 'created_at'], 'firefly_eda_outbox_pending_idx'); + $table->index(['channel', 'status'], 'firefly_eda_outbox_channel_status_idx'); + + self::claimColumns($table); + } + + /** The lease columns: in blueprint() for a fresh table, and alone for the migration that upgrades an old one. */ + public static function claimColumns(Blueprint $table): void + { + $table->string('claimed_by', 100)->nullable(); + $table->dateTime('claimed_until', 3)->nullable(); + $table->index(['status', 'claimed_until'], self::CLAIM_INDEX); + } +} diff --git a/packages/eda-database/src/Outbox/OutboxWriter.php b/packages/eda-database/src/Outbox/OutboxWriter.php new file mode 100644 index 00000000..e5aa5048 --- /dev/null +++ b/packages/eda-database/src/Outbox/OutboxWriter.php @@ -0,0 +1,12 @@ +, which is false here by construction; autowiring would otherwise use constructor + * defaults and put traceparents on a third party's wire with the compliance gate off. Validation is at RUN time (the + * relay command), not boot: a downstream bound in another provider's boot() may not exist yet when ours runs. + * + * Whatever it resolves to, an OutboxWriter is refused — the relay would re-INSERT every claimed row as PENDING into + * the outbox it is draining. Not `final`: the deprecated Firefly\Eda\Postgres\Outbox\RelayDownstream extends it. + */ +class RelayDownstream +{ + /** The config key that selects the relay's downstream. */ + public const string PROVIDER_KEY = 'firefly.eda.database.relay.downstream-provider'; + + /** The firefly/eda-postgres spelling of the same key, still honoured when the new one is unset. */ + public const string LEGACY_PROVIDER_KEY = 'firefly.eda.postgres.relay.downstream_provider'; + + /** The container id an app may bind to supply a fully constructed downstream publisher itself. */ + public const string BINDING = 'firefly.eda.relay.downstream'; + + /** @var array */ + private const ADAPTERS = [ + 'rabbitmq' => ['class' => 'Firefly\\Eda\\Rabbitmq\\RabbitMqEventPublisher', 'package' => 'firefly/eda-rabbitmq'], + 'kafka' => ['class' => 'Firefly\\Eda\\Kafka\\KafkaEventPublisher', 'package' => 'firefly/eda-kafka'], + ]; + + /** + * The configured downstream name (new key first, then the legacy one), or null when neither is set. A key present + * but null (an unset env()) counts as unset, so it never hides a legacy value. + */ + public static function configuredProvider(Config $config): ?string + { + $key = DatabaseOutboxSettings::firstSetKey($config, self::PROVIDER_KEY, self::LEGACY_PROVIDER_KEY); + $provider = $key === null ? '' : $config->string($key); + + return trim($provider) === '' ? null : trim($provider); + } + + /** Resolve the downstream publisher, or throw a ConfigurationException that names the exact thing to change. */ + public static function resolve(Container $container, Config $config): EventPublisher + { + $provider = self::configuredProvider($config); + + if ($container->bound(self::BINDING)) { + return self::guard($provider ?? self::BINDING, self::make($container, self::BINDING, [], $provider ?? self::BINDING)); + } + + if ($provider === null) { + throw new ConfigurationException(sprintf( + 'Neither %s nor its legacy alias %s is set, so firefly:outbox:relay has no downstream broker to forward to. ' + .'Set it to one of [%s], to the class-string of an EventPublisher, or bind your own under the container id ' + .'"%s". If you did not mean to front a second broker at all, do not run the relay: the outbox already ' + .'delivers committed rows in-process via `php artisan firefly:eda:consume`.', + self::PROVIDER_KEY, + self::LEGACY_PROVIDER_KEY, + implode('|', array_keys(self::ADAPTERS)), + self::BINDING, + )); + } + + return self::guard($provider, self::instantiate($container, $config, $provider)); + } + + private static function instantiate(Container $container, Config $config, string $provider): mixed + { + if ($container->bound($provider)) { + return self::make($container, $provider, [], $provider); + } + + $alias = isset(self::ADAPTERS[$provider]) ? $provider : self::shippedAdapterAlias($provider); + + if ($alias !== null) { + $adapter = self::ADAPTERS[$alias]; + + if (! class_exists($adapter['class'])) { + throw new ConfigurationException(sprintf( + '%s="%s" but %s is not installed — %s could not be found. Run `composer require %s`, or bind your own ' + .'downstream EventPublisher under the container id "%s".', + self::PROVIDER_KEY, + $provider, + $adapter['package'], + $adapter['class'], + $adapter['package'], + self::BINDING, + )); + } + + return self::make($container, $adapter['class'], self::parameters($container, $config, $alias), $provider); + } + + if (class_exists($provider)) { + return self::make($container, $provider, [], $provider); + } + + throw new ConfigurationException(sprintf( + '%s="%s" names neither a shipped adapter [%s], nor a bound container id, nor an existing class. Fix the ' + .'value, or bind your own downstream EventPublisher under the container id "%s".', + self::PROVIDER_KEY, + $provider, + implode('|', array_keys(self::ADAPTERS)), + self::BINDING, + )); + } + + private static function shippedAdapterAlias(string $provider): ?string + { + foreach (self::ADAPTERS as $alias => $adapter) { + if ($adapter['class'] === $provider) { + return $alias; + } + } + + return null; + } + + /** + * The adapter-specific constructor overrides that carry the sibling package's OWN configuration — and the gated + * EdaTracing — across the provider gate it is standing behind. + * + * @return array + */ + private static function parameters(Container $container, Config $config, string $provider): array + { + if ($provider === 'rabbitmq') { + return [ + 'exchange' => $config->string('firefly.eda.rabbitmq.exchange', 'firefly.events'), + 'tracing' => BrokerTracing::forContainer($container, $config), + ]; + } + + if ($provider === 'kafka') { + return [ + 'factory' => self::make( + $container, + 'Firefly\\Eda\\Kafka\\KafkaProducerFactory', + ['brokers' => $config->string('firefly.eda.kafka.brokers', '127.0.0.1:9092')], + $provider, + ), + 'tracing' => BrokerTracing::forContainer($container, $config), + ]; + } + + return []; + } + + /** + * @param array $parameters + */ + private static function make(Container $container, string $id, array $parameters, string $provider): mixed + { + try { + return $container->make($id, $parameters); + } catch (BindingResolutionException $e) { + throw new ConfigurationException(sprintf( + '%s="%s" resolved to "%s", which could not be constructed: %s. Bind a ready-made downstream ' + .'EventPublisher under the container id "%s" instead.', + self::PROVIDER_KEY, + $provider, + $id, + $e->getMessage(), + self::BINDING, + ), previous: $e); + } + } + + private static function guard(string $provider, mixed $downstream): EventPublisher + { + if ($downstream instanceof OutboxWriter) { + throw new ConfigurationException(sprintf( + '%s="%s" resolved to the outbox writer itself (%s). The relay would re-INSERT every claimed row into ' + .'firefly_eda_outbox as PENDING and claim it again forever. Point it at a DISTINCT broker, or bind one ' + .'under the container id "%s".', + self::PROVIDER_KEY, + $provider, + $downstream::class, + self::BINDING, + )); + } + + if (! $downstream instanceof EventPublisher) { + throw new ConfigurationException(sprintf( + '%s="%s" resolved to %s, which does not implement %s.', + self::PROVIDER_KEY, + $provider, + get_debug_type($downstream), + EventPublisher::class, + )); + } + + return $downstream; + } +} diff --git a/packages/eda-database/src/OutboxHealthIndicator.php b/packages/eda-database/src/OutboxHealthIndicator.php new file mode 100644 index 00000000..3d3cc994 --- /dev/null +++ b/packages/eda-database/src/OutboxHealthIndicator.php @@ -0,0 +1,49 @@ +config); + $connection = $settings->connectionFrom($this->connections); + + return Health::up([ + 'broker' => 'database', + 'driver' => $connection->getDriverName(), + 'claim-strategy' => OutboxClaimStrategies::resolve($connection, $settings->claimStrategy)->name(), + 'pending' => $connection->table(OutboxSchema::TABLE)->where('status', OutboxSchema::STATUS_PENDING)->count(), + ]); + } catch (Throwable $e) { + return Health::down(['broker' => 'database', 'error' => $e->getMessage()]); + } + } +} diff --git a/packages/eda-database/tests/CapstoneDatabaseOutboxTest.php b/packages/eda-database/tests/CapstoneDatabaseOutboxTest.php new file mode 100644 index 00000000..e21f8e0d --- /dev/null +++ b/packages/eda-database/tests/CapstoneDatabaseOutboxTest.php @@ -0,0 +1,85 @@ +app(); + + DB::transaction(fn () => $app->make(EventPublisher::class)->publish('orders', 'order.placed', ['id' => 7])); + + /** @var SubscriberRegistry $registry */ + $registry = $app->make(SubscriberRegistry::class); + /** @var EventConsumer $consumer */ + $consumer = $app->make(EventConsumer::class); + $processed = (new ConsumerLoop)->run($consumer, fn (EventEnvelope $envelope) => $registry->deliver($envelope), new ConsumerOptions(maxMessages: 1, pollTimeoutMs: 10)); + + /** @var ListenerSpy $spy */ + $spy = $app->make(ListenerSpy::class); + + expect($app->make(EventPublisher::class))->toBeInstanceOf(DatabaseEventPublisher::class) + ->and($consumer)->toBeInstanceOf(DatabaseEventConsumer::class) + ->and($processed)->toBe(1) + ->and($spy->seen)->toBe(['order.placed']) + ->and(DB::table(OutboxSchema::TABLE)->value('status'))->toBe(OutboxSchema::STATUS_PUBLISHED); +}); + +it('writes a domain event in the aggregate\'s transaction through the bound pre-commit hook, and drops it on rollback', function () { + /** @var DatabaseOutboxCapstoneTestCase $this */ + /** @var OutboxPreCommitHook $hook */ + $hook = $this->app()->make(OutboxPreCommitHook::class); + + DB::transaction(fn () => $hook->handle(new OrderPlaced(41), null)); + try { + DB::transaction(function () use ($hook): void { + $hook->handle(new OrderPlaced(42), null); + throw new RuntimeException('rolled back'); + }); + } catch (RuntimeException) { + // expected + } + + expect(DB::table(OutboxSchema::TABLE)->where('event_type', 'OrderPlaced')->count())->toBe(1) + ->and(DB::table(OutboxSchema::TABLE)->value('destination'))->toBe('cqrs.events'); +}); + +it('reports the outbox UP with the driver and the claim it will use', function () { + /** @var DatabaseOutboxCapstoneTestCase $this */ + $indicator = $this->app()->make(OutboxHealthIndicator::class); + + $health = $indicator->health(); + + expect($health->status)->toBe(Status::Up) + ->and($health->details)->toBe(['broker' => 'database', 'driver' => 'sqlite', 'claim-strategy' => 'optimistic-lease', 'pending' => 0]); +}); + +it('reports the outbox DOWN with the error when the forced claim strategy is one this server cannot honour', function () { + /** @var DatabaseOutboxCapstoneTestCase $this */ + config()->set('firefly.eda.database.claim-strategy', 'skip-locked'); + $indicator = $this->app()->make(OutboxHealthIndicator::class); + + $health = $indicator->health(); + + expect($health->status)->toBe(Status::Down) + ->and($health->details['broker'] ?? null)->toBe('database') + ->and($health->details['error'] ?? '')->toContain('claim-strategy="skip-locked"'); +}); diff --git a/packages/eda-database/tests/CapstoneFixtures/DatabaseCapstoneListener.php b/packages/eda-database/tests/CapstoneFixtures/DatabaseCapstoneListener.php new file mode 100644 index 00000000..6552b466 --- /dev/null +++ b/packages/eda-database/tests/CapstoneFixtures/DatabaseCapstoneListener.php @@ -0,0 +1,24 @@ +spy->record($envelope->eventType); + } +} diff --git a/packages/eda-database/tests/Claim/ClaimSqlShapeTest.php b/packages/eda-database/tests/Claim/ClaimSqlShapeTest.php new file mode 100644 index 00000000..12d6131b --- /dev/null +++ b/packages/eda-database/tests/Claim/ClaimSqlShapeTest.php @@ -0,0 +1,65 @@ + + */ +function claimSqlShape(string $driver, OutboxClaimStrategy $strategy): array +{ + $pdo = new PDO('sqlite::memory:'); + $config = ['driver' => $driver, 'name' => 'pretend']; + $connection = match ($driver) { + 'pgsql' => new PostgresConnection($pdo, 'pretend', '', $config), + 'mysql' => new MySqlConnection($pdo, 'pretend', '', $config), + 'mariadb' => new MariaDbConnection($pdo, 'pretend', '', $config), + 'sqlsrv' => new SqlServerConnection($pdo, 'pretend', '', $config), + default => throw new InvalidArgumentException('No grammar under test for '.$driver), + }; + $claim = OutboxClaim::at('node-a', 5, new DateTimeImmutable('2026-10-12 10:00:00', new DateTimeZone('UTC')), 300); + + return array_column($connection->pretend(static function () use ($connection, $strategy, $claim): void { + $strategy->claim($connection, $claim); + }), 'query'); +} + +it('claims with FOR UPDATE SKIP LOCKED on pgsql', function () { + expect(claimSqlShape('pgsql', new SkipLockedClaim))->toBe([ + 'select "id" from "firefly_eda_outbox" where "status" = \'PENDING\' and ("claimed_until" is null or "claimed_until" < \'2026-10-12 10:00:00.000\') order by "id" asc limit 5 for update skip locked', + ]); +}); + +it('claims with FOR UPDATE SKIP LOCKED under READ COMMITTED on mysql and mariadb', function (string $driver) { + expect(claimSqlShape($driver, new SkipLockedClaim))->toBe([ + 'set transaction isolation level read committed', + 'select `id` from `firefly_eda_outbox` where `status` = \'PENDING\' and (`claimed_until` is null or `claimed_until` < \'2026-10-12 10:00:00.000\') order by `id` asc limit 5 for update skip locked', + ]); +})->with(['mysql', 'mariadb']); + +it('claims with the UPDLOCK, READPAST, ROWLOCK table hint on sqlsrv', function () { + expect(claimSqlShape('sqlsrv', new ReadPastClaim))->toBe([ + 'select top 5 [id] from [firefly_eda_outbox] with (updlock, readpast, rowlock) where [status] = \'PENDING\' and ([claimed_until] is null or [claimed_until] < \'2026-10-12 10:00:00.000\') order by [id] asc', + ]); +}); + +it('reads candidates WITHOUT a lock for the optimistic lease, on any grammar', function () { + expect(claimSqlShape('mysql', new OptimisticLeaseClaim))->toBe([ + 'select `id` from `firefly_eda_outbox` where `status` = \'PENDING\' and (`claimed_until` is null or `claimed_until` < \'2026-10-12 10:00:00.000\') order by `id` asc limit 5', + ]); +}); diff --git a/packages/eda-database/tests/Claim/OptimisticLeaseClaimTest.php b/packages/eda-database/tests/Claim/OptimisticLeaseClaimTest.php new file mode 100644 index 00000000..669a4ddb --- /dev/null +++ b/packages/eda-database/tests/Claim/OptimisticLeaseClaimTest.php @@ -0,0 +1,74 @@ + OutboxSchema::blueprint($table)); + foreach (range(1, 5) as $n) { + DB::table(OutboxSchema::TABLE)->insert([ + 'destination' => 'orders', 'channel' => 'firefly_eda_events', 'event_type' => 'order.placed', + 'payload' => json_encode(['n' => $n], JSON_THROW_ON_ERROR), 'headers' => '{}', + 'status' => OutboxSchema::STATUS_PENDING, 'attempts' => 0, + ]); + } +}); + +function optimisticClaimAt(string $claimant, int $limit, string $now): OutboxClaim +{ + return OutboxClaim::at($claimant, $limit, new DateTimeImmutable($now, new DateTimeZone('UTC')), 60); +} + +function optimisticConnection(): Connection +{ + return DB::connection(); +} + +it('leases rows to one claimant at a time, and hands an ended lease to the next', function () { + $strategy = new OptimisticLeaseClaim; + + expect($strategy->claim(optimisticConnection(), optimisticClaimAt('a', 3, '2026-10-12 10:00:00')))->toBe([1, 2, 3]) + ->and($strategy->claim(optimisticConnection(), optimisticClaimAt('b', 3, '2026-10-12 10:00:01')))->toBe([4, 5]) + ->and($strategy->claim(optimisticConnection(), optimisticClaimAt('c', 3, '2026-10-12 10:00:30')))->toBe([]) + // a's lease (until 10:01:00.000) is over at 10:01:00.001; b's (until 10:01:01) is not. + ->and($strategy->claim(optimisticConnection(), optimisticClaimAt('c', 10, '2026-10-12 10:01:00.001')))->toBe([1, 2, 3]) + ->and(DB::table(OutboxSchema::TABLE)->where('claimed_by', 'c')->count())->toBe(3) + ->and(DB::table(OutboxSchema::TABLE)->where('id', 1)->value('claimed_until'))->toBe('2026-10-12 10:02:00.001'); +}); + +it('loses a row another claimant leased between its read and its conditional update', function () { + $intruded = false; + optimisticConnection()->beforeExecuting(static function (string $query) use (&$intruded): void { + if (! $intruded && str_starts_with($query, 'update')) { + $intruded = true; + DB::table(OutboxSchema::TABLE)->where('id', 1)->update(['claimed_by' => 'intruder', 'claimed_until' => '2999-01-01 00:00:00.000']); + } + }); + + expect((new OptimisticLeaseClaim)->claim(optimisticConnection(), optimisticClaimAt('a', 3, '2026-10-12 10:00:00')))->toBe([2, 3]) + ->and(DB::table(OutboxSchema::TABLE)->where('id', 1)->value('claimed_by'))->toBe('intruder'); +}); + +it('never offers a row that is no longer PENDING, and settles only the claimant\'s own rows', function () { + DB::table(OutboxSchema::TABLE)->where('id', 2)->update(['status' => OutboxSchema::STATUS_PUBLISHED]); + $claim = optimisticClaimAt('a', 5, '2026-10-12 10:00:00'); + + $ids = (new OptimisticLeaseClaim)->claim(optimisticConnection(), $claim); + + expect($ids)->toBe([1, 3, 4, 5]) + ->and(ClaimableRows::load(optimisticConnection(), $ids, 'a')->pluck('id')->all())->toBe([1, 3, 4, 5]) + ->and(ClaimableRows::load(optimisticConnection(), $ids, 'b')->all())->toBe([]) + ->and(ClaimableRows::owned(optimisticConnection(), 1, 'b')->count())->toBe(0) + ->and(ClaimableRows::owned(optimisticConnection(), 1, 'a')->count())->toBe(1); +}); diff --git a/packages/eda-database/tests/Claim/OutboxClaimStrategiesTest.php b/packages/eda-database/tests/Claim/OutboxClaimStrategiesTest.php new file mode 100644 index 00000000..6ae5fe60 --- /dev/null +++ b/packages/eda-database/tests/Claim/OutboxClaimStrategiesTest.php @@ -0,0 +1,53 @@ +name())->toBe($expected); +})->with([ + 'pgsql' => ['pgsql', '16.15', 'skip-locked'], + 'mysql 8.4' => ['mysql', '8.4.11', 'skip-locked'], + 'mysql 5.7' => ['mysql', '5.7.44', 'optimistic-lease'], + 'mariadb 11' => ['mariadb', '11.8.9-MariaDB', 'skip-locked'], + 'mariadb 10.5' => ['mariadb', '10.5.25-MariaDB', 'optimistic-lease'], + 'sqlsrv' => ['sqlsrv', '16.00.4135', 'read-past'], + 'sqlite' => ['sqlite', '3.46.1', 'optimistic-lease'], + 'unknown' => ['firebird', '5.0', 'optimistic-lease'], +]); + +it('honours a forced strategy the server supports and refuses one it does not', function () { + expect(OutboxClaimStrategies::forCapabilities(DatabaseCapabilities::fromVersion('pgsql', '16.15'), 'optimistic-lease'))->toBeInstanceOf(OptimisticLeaseClaim::class) + ->and(OutboxClaimStrategies::forCapabilities(DatabaseCapabilities::fromVersion('mysql', '8.4.11'), 'skip-locked'))->toBeInstanceOf(SkipLockedClaim::class) + ->and(OutboxClaimStrategies::forCapabilities(DatabaseCapabilities::fromVersion('sqlsrv', '16.00'), 'read-past'))->toBeInstanceOf(ReadPastClaim::class) + ->and(fn () => OutboxClaimStrategies::forCapabilities(DatabaseCapabilities::fromVersion('mysql', '5.7.44'), 'skip-locked')) + ->toThrow(ConfigurationException::class, 'mysql 5.7.44') + ->and(fn () => OutboxClaimStrategies::forCapabilities(DatabaseCapabilities::fromVersion('pgsql', '16.15'), 'read-past')) + ->toThrow(ConfigurationException::class, 'firefly.eda.database.claim-strategy="read-past"') + ->and(fn () => OutboxClaimStrategies::forCapabilities(DatabaseCapabilities::fromVersion('pgsql', '16.15'), 'fastest')) + ->toThrow(ConfigurationException::class, 'auto|skip-locked|read-past|optimistic-lease'); +}); + +it('resolves lazily, at the first claim, never at construction', function () { + $strategy = OutboxClaimStrategies::configured('auto'); + expect($strategy)->toBeInstanceOf(ConfiguredClaimStrategy::class) + ->and($strategy->name())->toBe('auto'); + + $connection = new SQLiteConnection(new PDO('sqlite::memory:'), ':memory:', '', ['name' => 'lazy']); + $connection->getSchemaBuilder()->create(OutboxSchema::TABLE, fn (Blueprint $table) => OutboxSchema::blueprint($table)); + + expect($strategy->claim($connection, OutboxClaim::at('a', 1, new DateTimeImmutable('now'), 1)))->toBe([]) + ->and($strategy->name())->toBe('optimistic-lease') + ->and(OutboxClaimStrategies::configured('optimistic-lease'))->toBeInstanceOf(OptimisticLeaseClaim::class); +}); diff --git a/packages/eda-database/tests/CompiledManifestFreshnessTest.php b/packages/eda-database/tests/CompiledManifestFreshnessTest.php new file mode 100644 index 00000000..e54194c0 --- /dev/null +++ b/packages/eda-database/tests/CompiledManifestFreshnessTest.php @@ -0,0 +1,30 @@ + dirname(__DIR__).'/src']; + $cache = dirname(__DIR__).'/cache'; + + $freshComponents = sys_get_temp_dir().'/firefly-eda-database-fresh-c-'.bin2hex(random_bytes(6)).'.php'; + $freshContext = sys_get_temp_dir().'/firefly-eda-database-fresh-x-'.bin2hex(random_bytes(6)).'.php'; + + try { + (new AutoConfigManifestCompiler)->write($src, $freshComponents, $freshContext); + + expect(ComponentManifest::load($freshComponents))->toEqual(ComponentManifest::load($cache.'/firefly-eda-database-components.php')) + ->and(ContextManifest::load($freshContext))->toEqual(ContextManifest::load($cache.'/firefly-eda-database-context.php')); + } finally { + @unlink($freshComponents); + @unlink($freshContext); + } +}); diff --git a/packages/eda-database/tests/DatabaseEventConsumerTest.php b/packages/eda-database/tests/DatabaseEventConsumerTest.php new file mode 100644 index 00000000..f38c9295 --- /dev/null +++ b/packages/eda-database/tests/DatabaseEventConsumerTest.php @@ -0,0 +1,140 @@ + Schema::create(OutboxSchema::TABLE, fn (Blueprint $table) => OutboxSchema::blueprint($table))); + +function consumerConnection(): Connection +{ + return DB::connection(); +} + +/** A consumer named $claimant whose clock reads $now (UTC) — lease expiry without sleeping. */ +function consumerAt(string $claimant, string $now, int $maxAttempts = 3): DatabaseEventConsumer +{ + return new DatabaseEventConsumer( + consumerConnection(), + new OptimisticLeaseClaim, + $maxAttempts, + leaseSeconds: 60, + claimant: $claimant, + awaitWork: static function (int $timeoutMs): void {}, + clock: static fn (): DateTimeImmutable => new DateTimeImmutable($now, new DateTimeZone('UTC')), + ); +} + +function consumerPublish(int $id): void +{ + (new DatabaseEventPublisher(consumerConnection(), new SubscriberRegistry))->publish('users', 'user.created', ['id' => $id]); +} + +it('delivers each committed row exactly once through ConsumerLoop and never grows the outbox', function () { + foreach ([1, 2, 3] as $id) { + consumerPublish($id); + } + $registry = new SubscriberRegistry; + $seen = []; + $registry->subscribe('user.*', function (EventEnvelope $envelope) use (&$seen): void { + $seen[] = $envelope->payload['id']; + }); + + $processed = (new ConsumerLoop)->run(consumerAt('a', '2026-10-12 10:00:00'), fn (EventEnvelope $e) => $registry->deliver($e), new ConsumerOptions(maxMessages: 3, pollTimeoutMs: 10)); + + expect($processed)->toBe(3) + ->and($seen)->toBe([1, 2, 3]) + ->and(DB::table(OutboxSchema::TABLE)->count())->toBe(3) + ->and(DB::table(OutboxSchema::TABLE)->where('status', OutboxSchema::STATUS_PUBLISHED)->whereNull('claimed_by')->count())->toBe(3); +}); + +it('keeps a claimed row from a second consumer until the lease ends, then hands it over', function () { + consumerPublish(7); + + $first = consumerAt('a', '2026-10-12 10:00:00')->poll(10); + expect($first)->toBeInstanceOf(ReceivedEnvelope::class) + ->and(consumerAt('b', '2026-10-12 10:00:59')->poll(10))->toBeNull(); + + // a "crashed" without acking; at 10:01:00.001 a's lease is over and b takes the row. + $consumerB = consumerAt('b', '2026-10-12 10:01:00.001'); + $second = $consumerB->poll(10); + assert($first instanceof ReceivedEnvelope && $second instanceof ReceivedEnvelope); + + // a comes back and acks: its lease is gone, so nothing happens; b's ack settles the row. + consumerAt('a', '2026-10-12 10:01:01')->ack($first); + expect(DB::table(OutboxSchema::TABLE)->value('status'))->toBe(OutboxSchema::STATUS_PENDING); + + $consumerB->ack($second); + expect(DB::table(OutboxSchema::TABLE)->value('status'))->toBe(OutboxSchema::STATUS_PUBLISHED); +}); + +it('nacks back to PENDING with the lease cleared, and FAILS past max attempts', function () { + consumerPublish(8); + $consumer = consumerAt('a', '2026-10-12 10:00:00', maxAttempts: 2); + + $received = $consumer->poll(10); + assert($received instanceof ReceivedEnvelope); + $consumer->nack($received); + $row = DB::table(OutboxSchema::TABLE)->first(); + expect($row?->status)->toBe(OutboxSchema::STATUS_PENDING) + ->and($row?->attempts)->toEqual(1) + ->and($row?->claimed_by)->toBeNull(); + + // Retried at once (no lease left to wait out), and the second failure is final. + $again = $consumer->poll(10); + assert($again instanceof ReceivedEnvelope); + $consumer->nack($again); + $row = DB::table(OutboxSchema::TABLE)->first(); + expect($row?->status)->toBe(OutboxSchema::STATUS_FAILED) + ->and($row?->failed_at)->not->toBeNull() + ->and($row?->error_message)->toBe('in-process handler failed'); +}); + +it('hands an undecodable row over as poison, which the loop dead-letters as FAILED without retry', function () { + DB::table(OutboxSchema::TABLE)->insert([ + 'destination' => 'users', 'channel' => 'firefly_eda_events', 'event_type' => 'user.created', + 'payload' => '{not json', 'headers' => '{}', 'status' => OutboxSchema::STATUS_PENDING, 'attempts' => 0, + ]); + + $processed = (new ConsumerLoop)->run(consumerAt('a', '2026-10-12 10:00:00'), fn () => null, new ConsumerOptions(maxMessages: 1, pollTimeoutMs: 10)); + $row = DB::table(OutboxSchema::TABLE)->first(); + + expect($processed)->toBe(1) + ->and($row?->status)->toBe(OutboxSchema::STATUS_FAILED) + ->and($row?->error_message)->toStartWith('undecodable outbox row:'); +}); + +it('waits for work only when there was nothing to claim', function () { + $waits = []; + $consumer = new DatabaseEventConsumer(consumerConnection(), new OptimisticLeaseClaim, awaitWork: static function (int $timeoutMs) use (&$waits): void { + $waits[] = $timeoutMs; + }); + + expect($consumer->poll(25))->toBeNull()->and($waits)->toBe([25]); + + consumerPublish(9); + expect($consumer->poll(25))->toBeInstanceOf(ReceivedEnvelope::class)->and($waits)->toBe([25]); +}); + +it('keeps PostgresEventConsumer\'s constructor and makes it a database consumer', function () { + $consumer = new PostgresEventConsumer(consumerConnection(), 'firefly_eda_events', 3, static function (int $timeoutMs): void {}); + + expect($consumer)->toBeInstanceOf(DatabaseEventConsumer::class) + ->and($consumer->claimant())->toMatch('/:\d+:[0-9a-f]{8}$/'); +}); diff --git a/packages/eda-database/tests/DatabaseEventPublisherTest.php b/packages/eda-database/tests/DatabaseEventPublisherTest.php new file mode 100644 index 00000000..2ae97ded --- /dev/null +++ b/packages/eda-database/tests/DatabaseEventPublisherTest.php @@ -0,0 +1,70 @@ + Schema::create(OutboxSchema::TABLE, fn (Blueprint $table) => OutboxSchema::blueprint($table))); + +it('writes one PENDING row that commits and rolls back with the caller\'s transaction', function () { + $publisher = new DatabaseEventPublisher(DB::connection(), new SubscriberRegistry); + + DB::transaction(fn () => $publisher->publish('orders', 'order.placed', ['id' => 1], ['x-correlation-id' => 'c-1'])); + + try { + DB::transaction(function () use ($publisher): void { + $publisher->publish('orders', 'order.placed', ['id' => 2]); + throw new RuntimeException('business failure after the outbox write'); + }); + } catch (RuntimeException) { + // expected + } + + $row = DB::table(OutboxSchema::TABLE)->first(); + + expect(DB::table(OutboxSchema::TABLE)->count())->toBe(1) + ->and($row?->status)->toBe(OutboxSchema::STATUS_PENDING) + ->and($row?->transaction_id)->toBe('c-1') + ->and($row?->channel)->toBe('firefly_eda_events') + ->and($row?->claimed_by)->toBeNull() + ->and(json_decode(is_string($row?->payload) ? $row->payload : '', true))->toBe(['id' => 1]); +}); + +it('records subscriptions on the shared registry the consumer delivers into', function () { + $registry = new SubscriberRegistry; + $seen = []; + (new DatabaseEventPublisher(DB::connection(), $registry))->subscribe('order.*', function (EventEnvelope $envelope) use (&$seen): void { + $seen[] = $envelope->eventType; + }); + + $registry->deliver(new EventEnvelope('order.placed', 'orders')); + + expect($seen)->toBe(['order.placed']); +}); + +it('is an outbox writer, and only its Postgres subclass notifies', function () { + $statements = []; + DB::listen(static function (QueryExecuted $query) use (&$statements): void { + $statements[] = $query->sql; + }); + + $database = new DatabaseEventPublisher(DB::connection(), new SubscriberRegistry, 'chan'); + $database->publish('orders', 'order.placed', ['id' => 3]); + + expect($database)->toBeInstanceOf(OutboxWriter::class) + ->and(new PostgresEventPublisher(DB::connection(), new SubscriberRegistry))->toBeInstanceOf(DatabaseEventPublisher::class) + ->and(implode("\n", $statements))->not->toContain('pg_notify') + ->and(DB::table(OutboxSchema::TABLE)->value('channel'))->toBe('chan'); +}); diff --git a/packages/eda-database/tests/DatabaseOutboxSettingsTest.php b/packages/eda-database/tests/DatabaseOutboxSettingsTest.php new file mode 100644 index 00000000..06d8b7c9 --- /dev/null +++ b/packages/eda-database/tests/DatabaseOutboxSettingsTest.php @@ -0,0 +1,54 @@ + $eda */ +function outboxSettings(array $eda): DatabaseOutboxSettings +{ + return DatabaseOutboxSettings::from(new Config(new Repository(['firefly' => ['eda' => $eda]]))); +} + +it('defaults to the application connection, auto claims, a five-minute lease', function () { + expect(outboxSettings([]))->toEqual(new DatabaseOutboxSettings(null, 'firefly_eda_events', 3, 'auto', 300.0, 500)); +}); + +it('reads the database keys', function () { + expect(outboxSettings(['database' => [ + 'connection' => 'mysql', 'channel' => 'orders', 'max-attempts' => 5, 'claim-strategy' => 'optimistic-lease', + 'lease-seconds' => 30, 'poll-interval-ms' => 250, + ]]))->toEqual(new DatabaseOutboxSettings('mysql', 'orders', 5, 'optimistic-lease', 30.0, 250)); +}); + +it('keeps every firefly.eda.postgres key working as an alias, and lets the database key win', function () { + expect(outboxSettings(['postgres' => ['connection' => 'pgsql', 'channel' => 'pgchan', 'max_attempts' => 7]])) + ->toEqual(new DatabaseOutboxSettings('pgsql', 'pgchan', 7, 'auto', 300.0, 500)) + ->and(outboxSettings([ + 'database' => ['connection' => 'mysql', 'max-attempts' => 2], + 'postgres' => ['connection' => 'pgsql', 'max_attempts' => 7], + ]))->toEqual(new DatabaseOutboxSettings('mysql', 'firefly_eda_events', 2, 'auto', 300.0, 500)); +}); + +it('treats a blank connection as the default one and floors nonsense values', function () { + expect(outboxSettings(['database' => ['connection' => '', 'max-attempts' => 0, 'lease-seconds' => 0, 'poll-interval-ms' => 1]])) + ->toEqual(new DatabaseOutboxSettings(null, 'firefly_eda_events', 1, 'auto', 1.0, 10)); +}); + +it('treats a connection key that is present but null (an unset env()) as the default connection', function () { + expect(outboxSettings(['database' => ['connection' => null]])->connection)->toBeNull() + ->and(outboxSettings(['postgres' => ['connection' => null]])->connection)->toBeNull(); +}); + +it('lets a database key that is present but null fall back to its postgres alias, then to the default', function () { + expect(outboxSettings([ + 'database' => ['connection' => null, 'channel' => null, 'max-attempts' => null], + 'postgres' => ['connection' => 'pgsql', 'channel' => 'pgchan', 'max_attempts' => 7], + ]))->toEqual(new DatabaseOutboxSettings('pgsql', 'pgchan', 7, 'auto', 300.0, 500)) + ->and(outboxSettings([ + 'database' => ['connection' => null, 'channel' => null, 'max-attempts' => null], + 'postgres' => ['connection' => null, 'channel' => null, 'max_attempts' => null], + ]))->toEqual(new DatabaseOutboxSettings(null, 'firefly_eda_events', 3, 'auto', 300.0, 500)); +}); diff --git a/packages/eda-database/tests/EdaDatabaseBootDiscoveryTest.php b/packages/eda-database/tests/EdaDatabaseBootDiscoveryTest.php new file mode 100644 index 00000000..da04040d --- /dev/null +++ b/packages/eda-database/tests/EdaDatabaseBootDiscoveryTest.php @@ -0,0 +1,36 @@ +instance('config', new Repository(['firefly' => ['eda' => ['provider' => $provider]]])); + $app->instance('files', new Filesystem); + $app->register(DatabaseServiceProvider::class); + $app->register(MigrationServiceProvider::class); + (new EdaDatabaseBootServiceProvider($app))->boot(); + (new EdaPostgresBootServiceProvider($app))->boot(); + /** @var Migrator $migrator */ + $migrator = $app->make('migrator'); + + $paths = []; + foreach ($migrator->paths() as $path) { + $paths[] = realpath($path); + } + + expect($paths)->toBe($loaded ? [realpath(dirname(__DIR__).'/database/migrations')] : []); + $app->flush(); +})->with([ + 'memory' => ['memory', false], + 'database' => ['database', true], + 'postgres' => ['postgres', true], +]); diff --git a/packages/eda-database/tests/Fixtures/OrderPlaced.php b/packages/eda-database/tests/Fixtures/OrderPlaced.php new file mode 100644 index 00000000..9a7f7f4c --- /dev/null +++ b/packages/eda-database/tests/Fixtures/OrderPlaced.php @@ -0,0 +1,16 @@ + */ + public array $published = []; + + public bool $fail = false; + + public function subscribe(string $eventTypePattern, callable $handler): void {} + + /** + * @param array $payload + * @param array $headers + */ + public function publish(string $destination, string $eventType, array $payload, array $headers = []): void + { + if ($this->fail) { + throw new RuntimeException('downstream down'); + } + + $this->published[] = new EventEnvelope($eventType, $destination, $payload, $headers); + } + + public function start(): void {} + + public function stop(): void {} +} diff --git a/packages/eda-database/tests/Integration/DatabaseOutboxRoundTripIntegrationTest.php b/packages/eda-database/tests/Integration/DatabaseOutboxRoundTripIntegrationTest.php new file mode 100644 index 00000000..dd4d1179 --- /dev/null +++ b/packages/eda-database/tests/Integration/DatabaseOutboxRoundTripIntegrationTest.php @@ -0,0 +1,157 @@ + IntegrationDsn::sqlitePath('outbox-roundtrip')] : []; + config([ + 'database.connections.it_writer' => IntegrationDsn::connectionConfig($lane, $overrides), + 'database.connections.it_consumer' => IntegrationDsn::connectionConfig($lane, $overrides), + ]); + DB::setDefaultConnection('it_writer'); + + return [DB::connection('it_writer'), DB::connection('it_consumer')]; +} + +/** Runs one direction of one of the package's anonymous migrations, on the default (writer) connection. */ +function roundTripMigrate(string $file, string $direction = 'up'): void +{ + $migration = require dirname(__DIR__, 2).'/database/migrations/'.$file; + + if (! $migration instanceof Migration || ! method_exists($migration, 'up') || ! method_exists($migration, 'down')) { + throw new RuntimeException('Expected an executable migration.'); + } + + $direction === 'down' ? $migration->down() : $migration->up(); +} + +function roundTripConsumer(Connection $connection, string $claimant, string $now, int $maxAttempts = 3): DatabaseEventConsumer +{ + return new DatabaseEventConsumer( + $connection, + OutboxClaimStrategies::resolve($connection), + $maxAttempts, + leaseSeconds: 60, + claimant: $claimant, + awaitWork: static function (int $timeoutMs): void {}, + clock: static fn (): DateTimeImmutable => new DateTimeImmutable($now, new DateTimeZone('UTC')), + ); +} + +foreach (IntegrationDsn::lanes() as $lane) { + it("upgrades and creates the outbox with its migrations on {$lane}", function () use ($lane): void { + roundTripSessions($lane); + Schema::dropIfExists(OutboxSchema::TABLE); + Schema::create(OutboxSchema::TABLE, function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->string('destination'); + $table->string('channel', 63); + $table->string('event_type'); + $table->json('payload'); + $table->json('headers'); + $table->string('transaction_id')->nullable(); + $table->string('status')->default(OutboxSchema::STATUS_PENDING); + $table->unsignedInteger('attempts')->default(0); + $table->text('error_message')->nullable(); + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('processed_at')->nullable(); + $table->timestamp('failed_at')->nullable(); + }); + + $upgrade = '2026_10_12_000000_add_claim_columns_to_firefly_eda_outbox_table.php'; + roundTripMigrate($upgrade); + expect(Schema::hasColumns(OutboxSchema::TABLE, ['claimed_by', 'claimed_until']))->toBeTrue(); + roundTripMigrate($upgrade, 'down'); + expect(Schema::hasColumn(OutboxSchema::TABLE, 'claimed_by'))->toBeFalse(); + + Schema::drop(OutboxSchema::TABLE); + roundTripMigrate('2026_07_27_000000_create_firefly_eda_outbox_table.php'); + roundTripMigrate($upgrade); + expect(Schema::hasColumns(OutboxSchema::TABLE, ['claimed_by', 'claimed_until']))->toBeTrue(); + Schema::drop(OutboxSchema::TABLE); + })->group('integration')->skip(IntegrationDsn::skipReason($lane) !== null, (string) IntegrationDsn::skipReason($lane)); + + it("writes same-transaction, leases, hands over on expiry, retries and relays on {$lane}", function () use ($lane): void { + [$writer, $reader] = roundTripSessions($lane); + $writer->getSchemaBuilder()->dropIfExists(OutboxSchema::TABLE); + $writer->getSchemaBuilder()->create(OutboxSchema::TABLE, fn (Blueprint $table) => OutboxSchema::blueprint($table)); + + try { + $publisher = new DatabaseEventPublisher($writer, new SubscriberRegistry); + + // (1) invisible to another session until the writer commits + $writer->transaction(function () use ($publisher, $reader): void { + $publisher->publish('orders', 'order.placed', ['n' => 1]); + // SQL Server without READ_COMMITTED_SNAPSHOT makes a plain read WAIT on the uncommitted row (here, forever: + // the writer is this process); READPAST reads what is committed, which is the claim being made. + $visible = $reader->table(OutboxSchema::TABLE); + expect(($reader->getDriverName() === 'sqlsrv' ? $visible->lock('with(readpast)') : $visible)->count())->toBe(0); + }); + expect($reader->table(OutboxSchema::TABLE)->where('status', OutboxSchema::STATUS_PENDING)->count())->toBe(1); + + // (2) a lease keeps a second consumer out until it ends; the late first claimant settles nothing + $first = roundTripConsumer($reader, 'a', '2026-10-12 10:00:00')->poll(10); + expect($first)->toBeInstanceOf(ReceivedEnvelope::class) + ->and(roundTripConsumer($reader, 'b', '2026-10-12 10:00:30')->poll(10))->toBeNull(); + $consumerB = roundTripConsumer($reader, 'b', '2026-10-12 10:01:00.500'); + $second = $consumerB->poll(10); + assert($first instanceof ReceivedEnvelope && $second instanceof ReceivedEnvelope); + expect($second->envelope?->payload)->toBe(['n' => 1]); + roundTripConsumer($reader, 'a', '2026-10-12 10:01:01')->ack($first); + expect($reader->table(OutboxSchema::TABLE)->value('status'))->toBe(OutboxSchema::STATUS_PENDING); + $consumerB->ack($second); + expect($reader->table(OutboxSchema::TABLE)->where('status', OutboxSchema::STATUS_PUBLISHED)->whereNotNull('processed_at')->count())->toBe(1); + + // (3) nack -> PENDING with attempts+1, then FAILED past max attempts + $publisher->publish('orders', 'order.updated', ['n' => 2]); + $retrying = roundTripConsumer($reader, 'c', '2026-10-12 11:00:00', maxAttempts: 2); + $received = $retrying->poll(10); + assert($received instanceof ReceivedEnvelope); + $retrying->nack($received); + expect($reader->table(OutboxSchema::TABLE)->where('event_type', 'order.updated')->value('status'))->toBe(OutboxSchema::STATUS_PENDING); + $again = $retrying->poll(10); + assert($again instanceof ReceivedEnvelope); + $retrying->nack($again); + expect($reader->table(OutboxSchema::TABLE)->where('event_type', 'order.updated')->value('status'))->toBe(OutboxSchema::STATUS_FAILED); + + // (4) the relay forwards to a distinct downstream and refuses the writer + $publisher->publish('billing', 'invoice.raised', ['n' => 3], ['x-c' => 'd']); + $downstream = new RecordingDownstream; + expect((new OutboxRelay($reader, $downstream, OutboxClaimStrategies::resolve($reader)))->relayBatch())->toBe(1) + ->and($downstream->published[0]->payload)->toBe(['n' => 3]) + ->and($downstream->published[0]->headers)->toBe(['x-c' => 'd']) + ->and(fn () => new OutboxRelay($reader, $publisher))->toThrow(LogicException::class); + } finally { + $writer->getSchemaBuilder()->dropIfExists(OutboxSchema::TABLE); + $writer->disconnect(); + $reader->disconnect(); + } + })->group('integration')->skip(IntegrationDsn::skipReason($lane) !== null, (string) IntegrationDsn::skipReason($lane)); +} diff --git a/packages/eda-database/tests/Integration/OutboxConcurrencyIntegrationTest.php b/packages/eda-database/tests/Integration/OutboxConcurrencyIntegrationTest.php new file mode 100644 index 00000000..2bcc4ff8 --- /dev/null +++ b/packages/eda-database/tests/Integration/OutboxConcurrencyIntegrationTest.php @@ -0,0 +1,122 @@ +isRunning(); + if (str_contains($process->getOutput(), 'ready')) { + return true; + } + if (! $running) { + return false; + } + usleep(1000); + } + + return false; +} + +/** Removes the files that exist — an `@unlink` of a missing file is still a warning the test run reports. */ +function outboxRaceRemoveFiles(string ...$paths): void +{ + foreach ($paths as $path) { + if (is_file($path)) { + unlink($path); + } + } +} +foreach (IntegrationDsn::lanes() as $lane) { + foreach (['relay', 'consume'] as $mode) { + foreach (['auto', 'optimistic-lease'] as $strategy) { + if ($lane === 'sqlite' && $strategy === 'auto') { + continue; + } + + it("delivers every row exactly once with two racing {$mode} processes on {$lane} ({$strategy})", function () use ($lane, $mode, $strategy): void { + $path = IntegrationDsn::sqlitePath('outbox-race'); + if ($lane === 'sqlite') { + outboxRaceRemoveFiles($path, $path.'-wal', $path.'-shm'); + } + $overrides = $lane === 'sqlite' ? ['database' => $path] : []; + $connection = IntegrationDsn::connect($lane, 'race-parent', $overrides); + $schema = $connection->getSchemaBuilder(); + $schema->dropIfExists(OutboxSchema::TABLE); + $schema->create(OutboxSchema::TABLE, fn (Blueprint $table) => OutboxSchema::blueprint($table)); + + $publisher = new DatabaseEventPublisher($connection, new SubscriberRegistry); + $connection->transaction(function () use ($publisher): void { + foreach (range(1, 200) as $n) { + $publisher->publish('orders', 'order.placed', ['n' => $n]); + } + }); + + $go = sys_get_temp_dir().'/firefly-outbox-race-'.bin2hex(random_bytes(6)); + $racers = []; + foreach (['racer-a', 'racer-b'] as $claimant) { + $arguments = [PHP_BINARY, '-d', 'memory_limit=512M', dirname(__DIR__).'/Support/outbox-racer.php', $lane, $mode, $strategy, $go, $claimant]; + if ($lane === 'sqlite') { + $arguments[] = $path; + } + $process = new Process($arguments, timeout: 120); + $process->start(); + $racers[] = $process; + } + + try { + foreach ($racers as $process) { + expect(outboxRaceAwaitReady($process))->toBeTrue($process->getErrorOutput()); + } + touch($go); + + $delivered = []; + foreach ($racers as $process) { + expect($process->wait())->toBe(0, $process->getErrorOutput()); + preg_match_all('/^delivered (\d+)$/m', $process->getOutput(), $matches); + foreach ($matches[1] as $n) { + $delivered[] = (int) $n; + } + } + sort($delivered); + + expect($delivered)->toBe(range(1, 200)) + ->and($connection->table(OutboxSchema::TABLE)->where('status', OutboxSchema::STATUS_PUBLISHED)->count())->toBe(200) + ->and($connection->table(OutboxSchema::TABLE)->whereNotNull('claimed_by')->count())->toBe(0); + } finally { + foreach ($racers as $process) { + $process->stop(); + } + outboxRaceRemoveFiles($go); + $schema->dropIfExists(OutboxSchema::TABLE); + $connection->disconnect(); + if ($lane === 'sqlite') { + outboxRaceRemoveFiles($path, $path.'-wal', $path.'-shm'); + } + } + })->group('integration')->skip(IntegrationDsn::skipReason($lane) !== null, (string) IntegrationDsn::skipReason($lane)); + } + } +} diff --git a/packages/eda-database/tests/LeaseClockTest.php b/packages/eda-database/tests/LeaseClockTest.php new file mode 100644 index 00000000..2390d761 --- /dev/null +++ b/packages/eda-database/tests/LeaseClockTest.php @@ -0,0 +1,27 @@ + new DateTimeImmutable('2026-10-12 12:00:00.123456', new DateTimeZone('+02:00'))); + + expect(LeaseClock::format($now))->toBe('2026-10-12 10:00:00.123') + ->and($now->getTimezone()->getName())->toBe('UTC'); +}); + +it('moves an instant by fractional seconds', function () { + $start = new DateTimeImmutable('2026-10-12 10:00:00.900', new DateTimeZone('UTC')); + + expect(LeaseClock::format(LeaseClock::plus($start, 0.25)))->toBe('2026-10-12 10:00:01.150') + ->and(LeaseClock::format(LeaseClock::plus($start, 300)))->toBe('2026-10-12 10:05:00.900'); +}); + +it('names a claimant after the host and the process, distinct per call', function () { + $a = LeaseClock::claimant(); + + expect($a)->toMatch('/^.+:\d+:[0-9a-f]{8}$/') + ->and(strlen($a))->toBeLessThanOrEqual(100) + ->and(LeaseClock::claimant())->not->toBe($a); +}); diff --git a/packages/eda-database/tests/OutboxProviderCoexistenceBootTest.php b/packages/eda-database/tests/OutboxProviderCoexistenceBootTest.php new file mode 100644 index 00000000..a9a196a5 --- /dev/null +++ b/packages/eda-database/tests/OutboxProviderCoexistenceBootTest.php @@ -0,0 +1,97 @@ +connection; + } + + public function getDefaultConnection(): string + { + return 'default'; + } + + public function setDefaultConnection($name): void {} + }; + + return fireflyApplication( + ['firefly' => ['eda' => ['provider' => $provider]]], + [DataServiceProvider::class, EdaDatabaseServiceProvider::class, EdaPostgresServiceProvider::class], + bindings: [ + ConnectionResolverInterface::class => $resolver, + HandlerManifest::class => new HandlerManifest([], []), + CorrelationContext::class => new CorrelationContext, + AggregateTracker::class => new AggregateTracker, + ApplicationEventPublisher::class => new class implements ApplicationEventPublisher + { + public function publish(object $event): void {} + }, + ], + ); +} + +/** @return list */ +function outboxCoexistenceDispatchers(Application $app): array +{ + /** @var FireflyKernel $kernel */ + $kernel = $app->make(FireflyKernel::class); + + $beans = []; + foreach ($kernel->context()->definitions->all() as $definition) { + foreach ($definition->descriptor->beans as $bean) { + if ($bean->returns === DomainEventDispatcher::class) { + $beans[] = $definition->class().'::'.$bean->method; + } + } + } + sort($beans); + + return $beans; +} + +it('lets exactly one dispatcher and one outbox health indicator survive per provider', function (string $provider, string $dispatcher, bool $database, bool $postgres) { + $app = outboxCoexistenceApp($provider); + /** @var ApplicationContext $context */ + $context = $app->make(ApplicationContext::class); + + expect(outboxCoexistenceDispatchers($app))->toBe([$dispatcher]) + ->and($context->has(OutboxHealthIndicator::class))->toBe($database) + ->and($context->has(PostgresHealthIndicator::class))->toBe($postgres); +})->with([ + 'database' => ['database', DatabaseOutboxAutoConfiguration::class.'::domainEventDispatcher', true, false], + 'postgres' => ['postgres', PostgresOutboxAutoConfiguration::class.'::domainEventDispatcher', false, true], + 'memory' => ['memory', DataAutoConfiguration::class.'::domainEventDispatcher', false, false], +]); diff --git a/packages/eda-database/tests/OutboxRelayCommandTest.php b/packages/eda-database/tests/OutboxRelayCommandTest.php new file mode 100644 index 00000000..b5b398cf --- /dev/null +++ b/packages/eda-database/tests/OutboxRelayCommandTest.php @@ -0,0 +1,42 @@ +insert([ + 'destination' => 'orders', 'channel' => 'firefly_eda_events', 'event_type' => 'order.created', + 'payload' => json_encode(['id' => $id], JSON_THROW_ON_ERROR), 'headers' => '{}', + 'status' => OutboxSchema::STATUS_PENDING, 'attempts' => 0, + ]); +} + +it('relays committed rows to the bound downstream under provider=database', function () { + /** @var DatabaseRelayCommandTestCase $this */ + $downstream = new RecordingDownstream; + $this->app()->instance(RelayDownstream::BINDING, $downstream); + relayCommandRow(3); + + expect($this->runRelay())->toBe(0) + ->and($downstream->published)->toHaveCount(1) + ->and(DB::table(OutboxSchema::TABLE)->value('status'))->toBe(OutboxSchema::STATUS_PUBLISHED); +}); + +it('fails before claiming anything when a forced claim strategy is unsupported', function () { + /** @var DatabaseRelayCommandTestCase $this */ + $this->app()->instance(RelayDownstream::BINDING, new RecordingDownstream); + config(['firefly.eda.database.claim-strategy' => 'skip-locked']); + relayCommandRow(4); + + expect($this->runRelay())->toBe(1) + ->and($this->relayOutput())->toContain('sqlite') + ->and(DB::table(OutboxSchema::TABLE)->whereNull('claimed_by')->where('status', OutboxSchema::STATUS_PENDING)->count())->toBe(1); +}); diff --git a/packages/eda-database/tests/OutboxRelayTest.php b/packages/eda-database/tests/OutboxRelayTest.php new file mode 100644 index 00000000..07d3e87c --- /dev/null +++ b/packages/eda-database/tests/OutboxRelayTest.php @@ -0,0 +1,99 @@ + OutboxSchema::blueprint($table)); + $publisher = new DatabaseEventPublisher(DB::connection(), new SubscriberRegistry); + foreach ([1, 2, 3] as $n) { + $publisher->publish('orders', 'order.placed', ['n' => $n], ['x-n' => (string) $n]); + } +}); + +function relayConnection(): Connection +{ + return DB::connection(); +} + +function relayAt(RecordingDownstream $downstream, string $claimant, string $now, int $batch = 50, int $maxAttempts = 3): OutboxRelay +{ + return new OutboxRelay( + relayConnection(), + $downstream, + new OptimisticLeaseClaim, + $batch, + $maxAttempts, + leaseSeconds: 60, + claimant: $claimant, + clock: static fn (): DateTimeImmutable => new DateTimeImmutable($now, new DateTimeZone('UTC')), + ); +} + +it('forwards each claimed row to the downstream, byte for byte, and marks it PUBLISHED', function () { + $downstream = new RecordingDownstream; + + expect(relayAt($downstream, 'r', '2026-10-12 10:00:00')->relayBatch())->toBe(3) + ->and(array_map(static fn (EventEnvelope $e): mixed => $e->payload['n'], $downstream->published))->toBe([1, 2, 3]) + ->and($downstream->published[0]->headers)->toBe(['x-n' => '1']) + ->and(DB::table(OutboxSchema::TABLE)->where('status', OutboxSchema::STATUS_PUBLISHED)->whereNull('claimed_by')->count())->toBe(3); +}); + +it('splits the outbox between two relays without forwarding a row twice', function () { + $a = new RecordingDownstream; + $b = new RecordingDownstream; + + expect(relayAt($a, 'a', '2026-10-12 10:00:00', batch: 2)->relayBatch())->toBe(2) + ->and(relayAt($b, 'b', '2026-10-12 10:00:00', batch: 2)->relayBatch())->toBe(1) + ->and(count($a->published) + count($b->published))->toBe(3); +}); + +it('counts a failed forward as an attempt and FAILS the row past max attempts', function () { + $downstream = new RecordingDownstream; + $downstream->fail = true; + + relayAt($downstream, 'r', '2026-10-12 10:00:00', maxAttempts: 2)->relayBatch(); + expect(DB::table(OutboxSchema::TABLE)->where('status', OutboxSchema::STATUS_PENDING)->where('attempts', 1)->whereNull('claimed_by')->count())->toBe(3); + + relayAt($downstream, 'r', '2026-10-12 10:00:01', maxAttempts: 2)->relayBatch(); + $row = DB::table(OutboxSchema::TABLE)->first(); + expect(DB::table(OutboxSchema::TABLE)->where('status', OutboxSchema::STATUS_FAILED)->count())->toBe(3) + ->and((string) json_encode($row?->error_message))->toContain('downstream down'); +}); + +it('refuses an outbox writer as its downstream', function () { + expect(fn () => new OutboxRelay(relayConnection(), new DatabaseEventPublisher(relayConnection(), new SubscriberRegistry))) + ->toThrow(LogicException::class, 'PostgresEventPublisher') + ->and(fn () => new OutboxRelay(relayConnection(), new PostgresEventPublisher(relayConnection(), new SubscriberRegistry))) + ->toThrow(LogicException::class, 'outbox writer'); +}); + +it('keeps the deprecated Postgres relay constructor working', function () { + $downstream = new RecordingDownstream; + + expect((new Firefly\Eda\Postgres\Outbox\OutboxRelay(relayConnection(), $downstream, 50, 3, true))->relayBatch())->toBe(3); +}); + +it('maps the deprecated useSkipLocked=false onto the portable lease, which two relays can share', function () { + $a = new RecordingDownstream; + $b = new RecordingDownstream; + + expect((new Firefly\Eda\Postgres\Outbox\OutboxRelay(relayConnection(), $a, 2, 3, false))->relayBatch())->toBe(2) + ->and((new Firefly\Eda\Postgres\Outbox\OutboxRelay(relayConnection(), $b, 2, 3, false))->relayBatch())->toBe(1) + ->and(count($a->published) + count($b->published))->toBe(3); +}); diff --git a/packages/eda-database/tests/OutboxSchemaTest.php b/packages/eda-database/tests/OutboxSchemaTest.php new file mode 100644 index 00000000..20a81acf --- /dev/null +++ b/packages/eda-database/tests/OutboxSchemaTest.php @@ -0,0 +1,66 @@ +down() : $migration->up(); +} + +it('creates the outbox with the lease columns a claim writes', function () { + outboxSchemaMigrate('2026_07_27_000000_create_firefly_eda_outbox_table.php'); + + expect(Schema::hasColumns(OutboxSchema::TABLE, ['id', 'destination', 'channel', 'event_type', 'payload', 'headers', 'transaction_id', 'status', 'attempts', 'error_message', 'created_at', 'processed_at', 'failed_at', 'claimed_by', 'claimed_until']))->toBeTrue(); + + // The second migration is a no-op on a table that already has the columns — a fresh install runs both. + outboxSchemaMigrate('2026_10_12_000000_add_claim_columns_to_firefly_eda_outbox_table.php'); + expect(Schema::hasColumn(OutboxSchema::TABLE, 'claimed_by'))->toBeTrue(); +}); + +it('adds the lease columns to an outbox created before them, and takes them away again', function () { + // The pre-W1 shape, as an application that migrated with 26.10.1 has it. + Schema::create(OutboxSchema::TABLE, function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->string('destination'); + $table->string('channel', 63); + $table->string('event_type'); + $table->json('payload'); + $table->json('headers'); + $table->string('transaction_id')->nullable(); + $table->string('status')->default(OutboxSchema::STATUS_PENDING); + $table->unsignedInteger('attempts')->default(0); + $table->text('error_message')->nullable(); + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('processed_at')->nullable(); + $table->timestamp('failed_at')->nullable(); + }); + + outboxSchemaMigrate('2026_10_12_000000_add_claim_columns_to_firefly_eda_outbox_table.php'); + expect(Schema::hasColumns(OutboxSchema::TABLE, ['claimed_by', 'claimed_until']))->toBeTrue(); + + outboxSchemaMigrate('2026_10_12_000000_add_claim_columns_to_firefly_eda_outbox_table.php', 'down'); + expect(Schema::hasColumn(OutboxSchema::TABLE, 'claimed_by'))->toBeFalse(); +}); + +it('keeps the old Postgres FQCNs working as deprecated subclasses', function () { + expect(new Firefly\Eda\Postgres\Outbox\OutboxSchema)->toBeInstanceOf(OutboxSchema::class) + ->and(Firefly\Eda\Postgres\Outbox\OutboxSchema::TABLE)->toBe('firefly_eda_outbox') + ->and(new Firefly\Eda\Postgres\Outbox\OutboxRow)->toBeInstanceOf(OutboxRow::class) + ->and(Firefly\Eda\Postgres\Outbox\OutboxRow::asMap('{"a":1}'))->toBe(['a' => 1]); +}); diff --git a/packages/eda-database/tests/ReflectionFreeEdaDatabaseTest.php b/packages/eda-database/tests/ReflectionFreeEdaDatabaseTest.php new file mode 100644 index 00000000..dee7dea2 --- /dev/null +++ b/packages/eda-database/tests/ReflectionFreeEdaDatabaseTest.php @@ -0,0 +1,26 @@ + basenames under $dir whose contents reference boot-time reflection */ +function edaDatabaseReflectionHits(string $dir): array +{ + $hits = []; + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)); + /** @var SplFileInfo $file */ + foreach ($files as $file) { + if (! $file->isFile() || $file->getExtension() !== 'php') { + continue; + } + if (preg_match('/ReflectionClass|ReflectionMethod|getAttributes/', (string) file_get_contents((string) $file->getRealPath())) === 1) { + $hits[] = $file->getBasename(); + } + } + sort($hits); + + return $hits; +} + +it('the firefly/eda-database adapter uses no boot-time reflection', function () { + expect(edaDatabaseReflectionHits(__DIR__.'/../src'))->toBe([]); +}); diff --git a/packages/eda-database/tests/RelayDownstreamTest.php b/packages/eda-database/tests/RelayDownstreamTest.php new file mode 100644 index 00000000..6f14a81e --- /dev/null +++ b/packages/eda-database/tests/RelayDownstreamTest.php @@ -0,0 +1,68 @@ + $values dotted key => value + * @return array{Container, Config} + */ +function downstreamEnv(array $values): array +{ + $repository = new Repository; + foreach ($values as $key => $value) { + $repository->set($key, $value); + } + + return [new Container, new Config($repository)]; +} + +it('resolves the new key, and the legacy Postgres key as its alias', function () { + [$container, $config] = downstreamEnv(['firefly.eda.database.relay.downstream-provider' => RecordingDownstream::class]); + expect(RelayDownstream::resolve($container, $config))->toBeInstanceOf(RecordingDownstream::class); + + [$container, $config] = downstreamEnv(['firefly.eda.postgres.relay.downstream_provider' => RecordingDownstream::class]); + expect(RelayDownstream::configuredProvider($config))->toBe(RecordingDownstream::class) + ->and(RelayDownstream::resolve($container, $config))->toBeInstanceOf(RecordingDownstream::class); +}); + +it('lets a new key that is present but null (an unset env()) fall back to the legacy key', function () { + [$container, $config] = downstreamEnv([ + 'firefly.eda.database.relay.downstream-provider' => null, + 'firefly.eda.postgres.relay.downstream_provider' => RecordingDownstream::class, + ]); + + expect(RelayDownstream::configuredProvider($config))->toBe(RecordingDownstream::class) + ->and(RelayDownstream::resolve($container, $config))->toBeInstanceOf(RecordingDownstream::class); + + [, $config] = downstreamEnv([ + 'firefly.eda.database.relay.downstream-provider' => null, + 'firefly.eda.postgres.relay.downstream_provider' => null, + ]); + expect(RelayDownstream::configuredProvider($config))->toBeNull(); +}); + +it('names both keys and the in-process alternative when neither is set', function () { + [$container, $config] = downstreamEnv([]); + + expect(fn () => RelayDownstream::resolve($container, $config)) + ->toThrow(ConfigurationException::class, 'firefly.eda.database.relay.downstream-provider') + ->and(fn () => RelayDownstream::resolve($container, $config)) + ->toThrow(ConfigurationException::class, 'firefly.eda.postgres.relay.downstream_provider'); +}); + +it('refuses the database outbox writer, however it was named', function () { + [$container, $config] = downstreamEnv(['firefly.eda.database.relay.downstream-provider' => 'rabbitmq']); + $container->instance(RelayDownstream::BINDING, new DatabaseEventPublisher(new SQLiteConnection(new PDO('sqlite::memory:'), ':memory:'), new SubscriberRegistry)); + + expect(fn () => RelayDownstream::resolve($container, $config))->toThrow(ConfigurationException::class, 'outbox writer'); +}); diff --git a/packages/eda-database/tests/Support/DatabaseOutboxCapstoneTestCase.php b/packages/eda-database/tests/Support/DatabaseOutboxCapstoneTestCase.php new file mode 100644 index 00000000..0c1835bb --- /dev/null +++ b/packages/eda-database/tests/Support/DatabaseOutboxCapstoneTestCase.php @@ -0,0 +1,69 @@ + */ + protected function fireflyProviders(): array + { + return [EdaServiceProvider::class, EdaWiringProvider::class, EdaDatabaseServiceProvider::class]; + } + + /** @return array */ + protected function configOverrides(): array + { + return ['firefly.eda.provider' => 'database']; + } + + protected function defineFireflyEnvironment(Application $app): void + { + $app->instance(EventListenerManifest::class, new EventListenerManifest( + (new EventListenerScanner)->scan([ + 'Firefly\\Eda\\Database\\Tests\\CapstoneFixtures\\' => dirname(__DIR__).'/CapstoneFixtures', + ]), + )); + $app->singleton(ListenerSpy::class); + + // The bare app registers neither firefly/cqrs nor firefly/data; the outbox beans name their collaborators as + // constructor parameters, so the empty real objects are supplied (never doubles). + $app->instance(HandlerManifest::class, new HandlerManifest([], [])); + $app->instance(CorrelationContext::class, new CorrelationContext); + $app->instance(AggregateTracker::class, new AggregateTracker); + $app->instance(ApplicationEventPublisher::class, new class implements ApplicationEventPublisher + { + public function publish(object $event): void {} + }); + } + + protected function setUp(): void + { + parent::setUp(); + + Schema::create(OutboxSchema::TABLE, fn (Blueprint $table) => OutboxSchema::blueprint($table)); + } +} diff --git a/packages/eda-database/tests/Support/DatabaseRelayCommandTestCase.php b/packages/eda-database/tests/Support/DatabaseRelayCommandTestCase.php new file mode 100644 index 00000000..7e4f131a --- /dev/null +++ b/packages/eda-database/tests/Support/DatabaseRelayCommandTestCase.php @@ -0,0 +1,55 @@ + */ + protected function fireflyProviders(): array + { + return [EdaDatabaseBootServiceProvider::class]; + } + + /** @return array */ + protected function configOverrides(): array + { + return ['firefly.eda.provider' => 'database']; + } + + protected function setUp(): void + { + parent::setUp(); + + Schema::create(OutboxSchema::TABLE, fn (Blueprint $table) => OutboxSchema::blueprint($table)); + } + + public function runRelay(): int + { + /** @var Kernel $kernel */ + $kernel = $this->app()->make(Kernel::class); + + return $kernel->call('firefly:outbox:relay', ['--max-messages' => 0]); + } + + public function relayOutput(): string + { + /** @var Kernel $kernel */ + $kernel = $this->app()->make(Kernel::class); + + return $kernel->output(); + } +} diff --git a/packages/eda-database/tests/Support/outbox-racer.php b/packages/eda-database/tests/Support/outbox-racer.php new file mode 100644 index 00000000..6d0e96c6 --- /dev/null +++ b/packages/eda-database/tests/Support/outbox-racer.php @@ -0,0 +1,92 @@ +" for every row it delivered, then "done". A 2 ms pause per delivery keeps both racers inside the + * claim at the same time; without it one racer can finish before the other has started. + */ +$lane = $argv[1] ?? ''; +$mode = $argv[2] ?? ''; +$strategy = $argv[3] ?? 'auto'; +$go = $argv[4] ?? ''; +$claimant = $argv[5] ?? 'racer'; +$connection = IntegrationDsn::connect($lane, 'racer', isset($argv[6]) ? ['database' => $argv[6]] : []); +$claim = OutboxClaimStrategies::configured($strategy); + +$report = static function (mixed $n): void { + fwrite(STDOUT, 'delivered '.(is_scalar($n) ? (string) $n : '?')."\n"); +}; + +$downstream = new class($report) implements EventPublisher +{ + /** @param Closure(mixed): void $report */ + public function __construct(private readonly Closure $report) {} + + public function subscribe(string $eventTypePattern, callable $handler): void {} + + /** + * @param array $payload + * @param array $headers + */ + public function publish(string $destination, string $eventType, array $payload, array $headers = []): void + { + usleep(2000); + ($this->report)($payload['n'] ?? null); + } + + public function start(): void {} + + public function stop(): void {} +}; + +$pending = static fn (): int => $connection->table(OutboxSchema::TABLE)->where('status', OutboxSchema::STATUS_PENDING)->count(); + +fwrite(STDOUT, "ready\n"); +fflush(STDOUT); + +$deadline = microtime(true) + 90; +while (! is_file($go) && microtime(true) < $deadline) { + usleep(1000); +} + +if ($mode === 'relay') { + $relay = new OutboxRelay($connection, $downstream, $claim, batchSize: 5, leaseSeconds: 120, claimant: $claimant); + while (microtime(true) < $deadline) { + if ($relay->relayBatch() === 0 && $pending() === 0) { + break; + } + } +} else { + $consumer = new DatabaseEventConsumer($connection, $claim, leaseSeconds: 120, claimant: $claimant, awaitWork: static function (int $timeoutMs): void { + usleep(1000); + }); + while (microtime(true) < $deadline) { + $received = $consumer->poll(10); + if ($received === null) { + if ($pending() === 0) { + break; + } + + continue; + } + usleep(2000); + $report($received->envelope?->payload['n'] ?? null); + $consumer->ack($received); + } +} + +fwrite(STDOUT, "done\n"); +$connection->disconnect(); diff --git a/packages/eda-kafka/tests/Integration/KafkaRoundTripTest.php b/packages/eda-kafka/tests/Integration/KafkaRoundTripTest.php index 77187d2b..c44682bd 100644 --- a/packages/eda-kafka/tests/Integration/KafkaRoundTripTest.php +++ b/packages/eda-kafka/tests/Integration/KafkaRoundTripTest.php @@ -13,6 +13,7 @@ use Firefly\Eda\Kafka\KafkaProducerFactory; use Firefly\Eda\Kafka\RdKafkaConsumerClient; use Firefly\Eda\Kafka\Tests\Support\KafkaIntegrationTestCase; +use Firefly\Testing\Integration\IntegrationDsn; uses(KafkaIntegrationTestCase::class); @@ -33,7 +34,7 @@ * real broker can prove, because producev() is the one call in this package that no unit test can reach. */ it('round-trips publish -> consume -> handler -> commit against a real Kafka, and commit() prevents redelivery', function () { - $brokers = (string) getenv('FIREFLY_KAFKA_BROKERS'); + $brokers = (string) IntegrationDsn::value(IntegrationDsn::KAFKA); $topic = 'firefly.eda.test.'.bin2hex(random_bytes(4)); $groupId = 'firefly-test-'.bin2hex(random_bytes(4)); @@ -72,12 +73,12 @@ expect($again)->toBeNull(); })->skip( - ! extension_loaded('rdkafka') || getenv('FIREFLY_KAFKA_BROKERS') === false, + ! extension_loaded('rdkafka') || IntegrationDsn::missing(IntegrationDsn::KAFKA), 'requires ext-rdkafka + FIREFLY_KAFKA_BROKERS', )->group('integration'); it('routes an exhausted retry (nack requeue:false) to the ".DLT" dead-letter topic', function () { - $brokers = (string) getenv('FIREFLY_KAFKA_BROKERS'); + $brokers = (string) IntegrationDsn::value(IntegrationDsn::KAFKA); $topic = 'firefly.eda.test.'.bin2hex(random_bytes(4)); $dltTopic = $topic.'.DLT'; @@ -136,6 +137,6 @@ ->and($headers['x-dlt-source-topic'] ?? null)->toBe($topic) ->and($headers['x-dlt-source-offset'] ?? null)->toMatch('/^\d+$/'); })->skip( - ! extension_loaded('rdkafka') || getenv('FIREFLY_KAFKA_BROKERS') === false, + ! extension_loaded('rdkafka') || IntegrationDsn::missing(IntegrationDsn::KAFKA), 'requires ext-rdkafka + FIREFLY_KAFKA_BROKERS', )->group('integration'); diff --git a/packages/eda-postgres/README.md b/packages/eda-postgres/README.md index c6be8d0c..fbbbf515 100644 --- a/packages/eda-postgres/README.md +++ b/packages/eda-postgres/README.md @@ -1,13 +1,11 @@ # firefly/eda-postgres -The GENUINE same-transaction outbox adapter for `firefly/eda`: `PostgresEventPublisher` writes the -`firefly_eda_outbox` row on the aggregate's own connection, inside the aggregate's own open transaction, -via `OutboxPreCommitHook` — an implementation of `firefly/data`'s `PreCommitEventHook` seam — so the row -commits or rolls back atomically with the aggregate, no dual-write. An in-process `LISTEN`/`NOTIFY` + -poll `PostgresEventConsumer` claims committed `PENDING` rows (`SELECT ... FOR UPDATE SKIP LOCKED`), -drives matching `#[EventListener]` handlers, and marks them `PUBLISHED` (or `FAILED` past -`max_attempts`). An optional `firefly:outbox:relay` command forwards `PENDING` rows to a distinct -downstream broker. Auto-configured behind `firefly.eda.provider=postgres`, with a `PostgresHealthIndicator`. +The Postgres flavour of LaraFly's same-transaction outbox: `firefly/eda-database`'s outbox — the row written on +the aggregate's own connection inside its open transaction, rows claimed by leases so any number of consumers and +relays can run — plus two Postgres optimizations: `PostgresEventPublisher` raises `pg_notify` inside that +transaction, and `PostgresEventConsumer` `LISTEN`s and wakes on it. Auto-configured behind +`firefly.eda.provider=postgres`, with a `PostgresHealthIndicator`; the `firefly.eda.postgres.*` keys are aliases of +`firefly.eda.database.*`. ```bash composer require fireflyframework/larafly @@ -27,13 +25,14 @@ php artisan firefly:eda:consume # terminal in-process delivery bound `EventPublisher` — under this provider, `PostgresEventPublisher` — which records them on the **shared** `SubscriberRegistry` singleton. `firefly:eda:consume` resolves that same registry and feeds every claimed row into it, acking a row only after delivery returns. A handler that throws leaves the row `PENDING` with -`attempts + 1` (or `FAILED` past `max_attempts`), so a failed delivery is retried rather than lost. +`attempts + 1` (or `FAILED` past `max-attempts`), so a failed delivery is retried rather than lost. ## The optional relay `firefly:outbox:relay` forwards committed `PENDING` rows to a **distinct** downstream broker. It is genuinely optional: an app whose only consumers are `#[EventListener]` handlers never runs it. When you do, name the -downstream with `firefly.eda.postgres.relay.downstream_provider`, which accepts: +downstream with `firefly.eda.database.relay.downstream-provider` (alias +`firefly.eda.postgres.relay.downstream_provider`), which accepts: * `rabbitmq` / `kafka` — the shipped adapter, built from that package's own config keys (`firefly.eda.rabbitmq.exchange`, `firefly.eda.kafka.brokers`); @@ -49,6 +48,7 @@ claimed row as `PENDING` and claim it again forever. An unset or unresolvable va (exit 1, naming the remedy) raised before any row is claimed — never a silent no-op that marks rows `PUBLISHED` without forwarding them. -See [EDA Brokers](../../docs/modules/eda-brokers.md) for the full outbox reference. +See [EDA Brokers](../../docs/modules/eda-brokers.md) and [EDA Database Outbox](../../docs/modules/eda-database.md) +for the full outbox reference. Apache-2.0 © Firefly Software Solutions Inc. diff --git a/packages/eda-postgres/composer.json b/packages/eda-postgres/composer.json index 7604529f..ffa76fd2 100644 --- a/packages/eda-postgres/composer.json +++ b/packages/eda-postgres/composer.json @@ -1,6 +1,6 @@ { "name": "firefly/eda-postgres", - "description": "LaraFly eda Postgres adapter: a PostgresEventPublisher backed by a GENUINE same-transaction firefly_eda_outbox table (the outbox row commits atomically with the aggregate), an in-process LISTEN/NOTIFY + poll consumer that claims committed PENDING rows (SELECT ... FOR UPDATE SKIP LOCKED), drives #[EventListener] handlers, and marks them PUBLISHED, plus an OPTIONAL firefly:outbox:relay command that forwards PENDING rows to a distinct downstream broker. Auto-configured behind firefly.eda.provider=postgres.", + "description": "LaraFly eda Postgres adapter: firefly/eda-database's GENUINE same-transaction firefly_eda_outbox (leased claims, safe with concurrent consumers and relays) plus Postgres's two optimizations — pg_notify on write, inside the aggregate's transaction, and a LISTEN wake-up in the in-process consumer. Auto-configured behind firefly.eda.provider=postgres; the firefly.eda.postgres.* keys are aliases of firefly.eda.database.*.", "type": "library", "license": "Apache-2.0", "homepage": "https://github.com/fireflyframework/fireflyframework-php", @@ -32,6 +32,7 @@ "firefly/data": "*@dev", "firefly/domain": "*@dev", "firefly/eda": "*@dev", + "firefly/eda-database": "*@dev", "firefly/kernel": "*@dev", "illuminate/console": "^13.0", "illuminate/contracts": "^13.0", diff --git a/packages/eda-postgres/src/Console/OutboxRelayCommand.php b/packages/eda-postgres/src/Console/OutboxRelayCommand.php index 88301549..c6ce73d9 100644 --- a/packages/eda-postgres/src/Console/OutboxRelayCommand.php +++ b/packages/eda-postgres/src/Console/OutboxRelayCommand.php @@ -4,105 +4,7 @@ namespace Firefly\Eda\Postgres\Console; -use Firefly\Config\Config; -use Firefly\Eda\Postgres\Outbox\OutboxRelay; -use Firefly\Eda\Postgres\Outbox\RelayDownstream; -use Firefly\Eda\Tracing\BrokerTracing; -use Firefly\Kernel\Exception\Framework\ConfigurationException; -use Illuminate\Console\Command; -use Illuminate\Contracts\Container\Container; -use Illuminate\Database\Connection; -use Illuminate\Database\ConnectionResolverInterface; +use Firefly\Eda\Database\Console\OutboxRelayCommand as DatabaseOutboxRelayCommand; -/** - * OPTIONAL: forwards committed firefly_eda_outbox PENDING rows to a DISTINCT downstream broker selected by - * firefly.eda.postgres.relay.downstream_provider. Terminal in-process delivery is NOT this command's job — - * firefly:eda:consume owns that — so an app that only needs #[EventListener] handlers never runs this at all. - * - * WHAT CHANGED AND WHY. This command used to treat downstream_provider as a mere on/off flag and then resolve - * EventPublisher::class from the container. Under firefly.eda.provider=postgres — the only provider that has an - * outbox table to relay — that binding IS the outbox writer, so the guard below rejected the one thing the command - * could ever resolve, and the relay could not be made to work under any configuration. Selection now goes through - * RelayDownstream (see its docblock for the full resolution order and why the sibling adapter packages are - * referenced as class-strings), which returns a REAL downstream publisher or throws a ConfigurationException - * naming exactly what to change. - * - * Both failure modes are now loud. An UNSET key is no longer an exit-0 "no-op": running a relay with nothing to - * relay to is a misconfiguration, and exiting 0 while forwarding nothing is precisely the silent behaviour that hid - * the defect — so it exits FAILURE with a message pointing at firefly:eda:consume for the in-process case. A - * MISCONFIGURED key (unknown name, package not installed, unconstructible class, wrong type, or the outbox writer - * itself) is reported before a single row is claimed, so a failed relay can never mark rows PUBLISHED. - */ -final class OutboxRelayCommand extends Command -{ - /** @var string */ - protected $signature = 'firefly:outbox:relay {--max-messages= : stop after N published} {--time-limit= : stop after N seconds} {--sleep=1 : seconds between empty batches} {--batch-size=50}'; - - /** @var string */ - protected $description = 'OPTIONAL: forward committed firefly_eda_outbox rows to the downstream broker named by firefly.eda.postgres.relay.downstream_provider (claim -> publish -> mark PUBLISHED/FAILED).'; - - public function handle(ConnectionResolverInterface $connections, Container $container, Config $config): int - { - try { - $downstream = RelayDownstream::resolve($container, $config); - } catch (ConfigurationException $e) { - // Reported as a clean console error rather than an escaping exception: the operator needs the remedy, - // not a stack trace through the container. - $this->error($e->getMessage()); - - return self::FAILURE; - } - - $name = $config->has('firefly.eda.postgres.connection') ? $config->string('firefly.eda.postgres.connection') : null; - $conn = $connections->connection($name); - $useSkipLocked = $conn instanceof Connection && $conn->getDriverName() === 'pgsql'; // narrow ONLY to read the driver (B2) - - $relay = new OutboxRelay( - $conn, - $downstream, - (int) $this->option('batch-size'), - $config->int('firefly.eda.postgres.max_attempts', 3), - $useSkipLocked, - // The UNGATED seam: the relay uses it to CONTINUE the traceparent the claimed row already carries, so - // the downstream producer span is that trace's child rather than a new root. Writing to a third party's - // wire is the downstream publisher's job, and RelayDownstream has already gated THAT on - // firefly.eda.tracing.brokers.enabled. - BrokerTracing::bound($container), - ); - - $maxMessages = $this->option('max-messages'); - $timeLimit = $this->option('time-limit'); - $deadline = is_numeric($timeLimit) ? time() + (int) $timeLimit : null; - $sleep = max(0, (int) $this->option('sleep')); - $published = 0; - - if (function_exists('pcntl_async_signals')) { - pcntl_async_signals(true); - $stop = false; - pcntl_signal(SIGINT, function () use (&$stop): void { - $stop = true; - }); - pcntl_signal(SIGTERM, function () use (&$stop): void { - $stop = true; - }); - } - - while (true) { - $n = $relay->relayBatch(); - $published += $n; - - if (($maxMessages !== null && is_numeric($maxMessages) && $published >= (int) $maxMessages) - || ($deadline !== null && time() >= $deadline) - || (isset($stop) && $stop)) { - break; - } - if ($n === 0) { - sleep($sleep); - } - } - - $this->info("firefly:outbox:relay — published {$published} row(s)."); - - return self::SUCCESS; - } -} +/** @deprecated since W1 — use Firefly\Eda\Database\Console\OutboxRelayCommand (same signature, either provider). */ +final class OutboxRelayCommand extends DatabaseOutboxRelayCommand {} diff --git a/packages/eda-postgres/src/EdaPostgresBootServiceProvider.php b/packages/eda-postgres/src/EdaPostgresBootServiceProvider.php index 1d6586ea..a27a7268 100644 --- a/packages/eda-postgres/src/EdaPostgresBootServiceProvider.php +++ b/packages/eda-postgres/src/EdaPostgresBootServiceProvider.php @@ -4,10 +4,14 @@ namespace Firefly\Eda\Postgres; -use Firefly\Eda\Postgres\Console\OutboxRelayCommand; +use Firefly\Eda\Database\Console\OutboxRelayCommand; +use Firefly\Eda\Database\EdaDatabaseBootServiceProvider; use Illuminate\Support\ServiceProvider; -/** Ships the outbox migration + (Task 5) the OPTIONAL firefly:outbox:relay command via auto-discovery — no frozen edit. */ +/** + * Ships the outbox migrations (firefly/eda-database's, the one outbox table) and the OPTIONAL firefly:outbox:relay + * command under provider=postgres via auto-discovery. + */ final class EdaPostgresBootServiceProvider extends ServiceProvider { public function boot(): void @@ -16,7 +20,7 @@ public function boot(): void return; } - $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); + $this->loadMigrationsFrom(EdaDatabaseBootServiceProvider::MIGRATIONS); if ($this->app->runningInConsole()) { $this->commands([OutboxRelayCommand::class]); diff --git a/packages/eda-postgres/src/Outbox/OutboxPreCommitHook.php b/packages/eda-postgres/src/Outbox/OutboxPreCommitHook.php index 90e7394a..25a292b0 100644 --- a/packages/eda-postgres/src/Outbox/OutboxPreCommitHook.php +++ b/packages/eda-postgres/src/Outbox/OutboxPreCommitHook.php @@ -4,66 +4,23 @@ namespace Firefly\Eda\Postgres\Outbox; -use Firefly\Cqrs\Correlation\CorrelationContext; -use Firefly\Cqrs\Event\EdaCommandEventPublisher; -use Firefly\Data\Domain\PreCommitEventHook; -use Firefly\Domain\DomainEvent; -use Firefly\Eda\Bus\SubscriberRegistry; +use Firefly\Eda\Database\DatabaseEventPublisher; +use Firefly\Eda\Database\Outbox\OutboxPreCommitHook as DatabaseOutboxPreCommitHook; use Firefly\Eda\Postgres\PostgresEventPublisher; -use Firefly\Eda\Tracing\EdaTracing; use Illuminate\Database\Connection; -use Illuminate\Database\ConnectionResolverInterface; +use Illuminate\Database\ConnectionInterface; /** - * Bridges M8's in-tx pre-commit drain to the Postgres outbox. handle($event, $connection) runs synchronously inside - * the aggregate's tx (DomainEventDispatcher calls it during the pre-commit drain); for a DomainEvent it resolves the - * AGGREGATE'S OWN connection ($connection, threaded from the dispatcher — I1) and builds a PostgresEventPublisher on it - * (emitNotify enabled for pgsql), then delegates to an EdaCommandEventPublisher carrying HandlerManifest::destinations() - * + CorrelationContext (M5), so the domain->envelope mapping (eventType/payload/per-event destination/transaction_id) - * is IDENTICAL to the after-commit bridge — but the resulting INSERT is same-tx on the aggregate's connection. - * Non-DomainEvent objects are ignored (the generic after-commit dispatch still handles them). Reflection-free. - * - * The SubscriberRegistry it carries is the app's SHARED singleton, threaded straight through to the per-event - * PostgresEventPublisher. The hook itself never subscribes anything — it is a pure write path — but the publisher's - * subscribe() is what EventListenerWiringPass drives, and making the registry a REQUIRED constructor dependency of - * PostgresEventPublisher is exactly what makes the silent "listener subscribed into the void" regression - * unconstructible (see PostgresEventPublisher's docblock). Passing the shared instance rather than a throwaway - * `new SubscriberRegistry` keeps a single registry per application, so there is never a second, invisible one to - * reason about when debugging a missing delivery. - * - * THE TRACING IT CARRIES IS NOT OPTIONAL DECORATION. Under firefly.eda.provider=postgres, - * PostgresOutboxAutoConfiguration::commandEventPublisher() deliberately NoOps the after-commit eda leg so a domain - * event reaches the outbox EXACTLY ONCE — in-tx, here. That makes this hook the ONLY writer of a DomainEvent's - * outbox row: the bound EventPublisher bean is reached only by application code calling publish() by hand. So the - * EdaTracing this class threads into the per-event PostgresEventPublisher is what decides whether the FLAGSHIP - * rows — the ones that commit with the aggregate — carry a `traceparent` at all. It is the gated one - * (BrokerTracing::resolve(), honouring firefly.eda.tracing.brokers.enabled), identical to the bean's, so the two - * write paths are never traced differently. Null keeps the constructor usable from a test that does not care. + * firefly/eda-database's pre-commit hook with the Postgres writer: on a pgsql connection the per-event + * PostgresEventPublisher also fires the in-transaction pg_notify. Same constructor, same mapping, same tracing and + * registry rules — see the parent's docblock. */ -final class OutboxPreCommitHook implements PreCommitEventHook +final class OutboxPreCommitHook extends DatabaseOutboxPreCommitHook { - /** @param array $destinations eventClass => destination */ - public function __construct( - private readonly ConnectionResolverInterface $connections, - private readonly SubscriberRegistry $registry, - private readonly string $channel, - private readonly string $defaultDestination, - private readonly array $destinations, - private readonly ?CorrelationContext $correlation = null, - private readonly ?EdaTracing $tracing = null, - ) {} - - public function handle(object $event, ?string $connection = null): void + protected function writerFor(ConnectionInterface $connection): DatabaseEventPublisher { - if (! $event instanceof DomainEvent) { - return; - } - - $conn = $this->connections->connection($connection); // the aggregate's connection — carries the open tx - $emitNotify = $conn instanceof Connection && $conn->getDriverName() === 'pgsql'; - $publisher = new PostgresEventPublisher($conn, $this->registry, $this->channel, $emitNotify, $this->tracing); + $emitNotify = $connection instanceof Connection && $connection->getDriverName() === 'pgsql'; - (new EdaCommandEventPublisher($publisher, $this->defaultDestination, $this->destinations, $this->correlation)) - ->publish($event); + return new PostgresEventPublisher($connection, $this->registry, $this->channel, $emitNotify, $this->tracing); } } diff --git a/packages/eda-postgres/src/Outbox/OutboxRelay.php b/packages/eda-postgres/src/Outbox/OutboxRelay.php index 63e4d19c..0af81e36 100644 --- a/packages/eda-postgres/src/Outbox/OutboxRelay.php +++ b/packages/eda-postgres/src/Outbox/OutboxRelay.php @@ -4,130 +4,44 @@ namespace Firefly\Eda\Postgres\Outbox; -use Firefly\Eda\EventEnvelope; +use Firefly\Eda\Database\Outbox\Claim\OptimisticLeaseClaim; +use Firefly\Eda\Database\Outbox\Claim\OutboxClaimStrategies; +use Firefly\Eda\Database\Outbox\OutboxRelay as DatabaseOutboxRelay; use Firefly\Eda\EventPublisher; -use Firefly\Eda\Postgres\PostgresEventPublisher; use Firefly\Eda\Tracing\EdaTracing; -use Firefly\Eda\Tracing\NoOpEdaTracing; +use Illuminate\Database\Connection; use Illuminate\Database\ConnectionInterface; -use Illuminate\Support\Collection; -use LogicException; -use Throwable; +use InvalidArgumentException; /** - * The OPTIONAL store-and-forward relay: claims committed PENDING outbox rows and publishes them to a DISTINCT - * downstream broker (a Kafka/RabbitMQ EventPublisher), marking each PUBLISHED or (past maxAttempts) FAILED. It NEVER - * accepts a PostgresEventPublisher downstream — that would re-INSERT PENDING rows into the same outbox (infinite loop), - * so the ctor guards with a LogicException (B1). Terminal in-process delivery is NOT this class's job — PostgresEventConsumer - * owns it. On Postgres the claim uses `FOR UPDATE SKIP LOCKED` (via ->lock('for update skip locked') — the installed - * Laravel Builder has no skipLocked() helper) inside a short tx so concurrent relay workers never double-claim; on - * sqlite (unit tests) useSkipLocked=false ⇒ a plain SELECT + per-row UPDATE guard (`WHERE id=? AND status='PENDING'`) - * gives the same at-least-once dedup single-threaded. The claim/mark is the idempotency: a PUBLISHED row is never - * re-selected. A single relay worker gives effective FIFO (ORDER BY id). The caller passes useSkipLocked (computed from - * the concrete Connection's driver) so this class needs only ConnectionInterface (table/raw/transaction). - * - * THE RELAY HOP IS A CONTINUATION, NOT A NEW TRACE. A claimed row already carries the `traceparent` the producer - * span stamped on it when it was INSERTed in the aggregate's transaction. Forwarding those headers straight to - * $downstream->publish() destroyed it: the downstream adapter publishes through EdaTracing::tracePublish(), which - * starts its span with no explicit parent — and on a relay worker there is no current span, so it started a fresh - * ROOT — then merges its own injected headers LAST, overwriting the row's traceparent with an unrelated one. A row - * published under T1 reached the broker under T2 and the consumer on the far side continued the wrong trace: the - * same "trace of half a system" the producer spans were added to fix, reintroduced one hop later. So the forward - * runs inside traceConsume() of an envelope rebuilt from the row — the identical rule SubscriberRegistrySink - * applies on the in-process path — which makes the row's traceparent the CONSUMER span's remote parent and the - * downstream producer span that span's child. The seam is the UNGATED bound EdaTracing: traceConsume writes - * nothing on any wire, so firefly.eda.tracing.brokers.enabled has no business silencing it — that key gates the - * downstream PUBLISHER, which RelayDownstream builds (see its parameters()). + * @deprecated since W1 — use Firefly\Eda\Database\Outbox\OutboxRelay. This keeps the old constructor for one release. + * `$useSkipLocked` keeps its intent, mapped onto the lease claims: `true` ("lock the rows I take") becomes + * the capability-probed `auto` strategy — SKIP LOCKED wherever the server supports it, never an SQL error + * where it does not — and `false` ("take no row locks") becomes the portable optimistic lease, which + * also takes none. The old `false` was an unlocked read that two relays could double-forward; the + * optimistic lease is not. */ -final class OutboxRelay +final class OutboxRelay extends DatabaseOutboxRelay { - private readonly EdaTracing $tracing; - public function __construct( - private readonly ConnectionInterface $connection, - private readonly EventPublisher $downstream, - private readonly int $batchSize = 50, - private readonly int $maxAttempts = 3, - private readonly bool $useSkipLocked = false, + ConnectionInterface $connection, + EventPublisher $downstream, + int $batchSize = 50, + int $maxAttempts = 3, + bool $useSkipLocked = false, ?EdaTracing $tracing = null, ) { - $this->tracing = $tracing ?? new NoOpEdaTracing; - - if ($downstream instanceof PostgresEventPublisher) { - throw new LogicException('OutboxRelay downstream must NOT be a PostgresEventPublisher — it would re-insert PENDING rows into the same outbox (infinite loop). Configure firefly.eda.postgres.relay.downstream_provider=rabbitmq|kafka.'); - } - } - - public function relayBatch(): int - { - if ($this->useSkipLocked) { - /** @var int $relayed */ - $relayed = $this->connection->transaction(fn (): int => $this->processBatch($this->claim())); - - return $relayed; - } - - return $this->processBatch($this->claim()); - } - - /** @return Collection */ - private function claim(): Collection - { - $query = $this->connection->table(OutboxSchema::TABLE) - ->where('status', OutboxSchema::STATUS_PENDING) - ->orderBy('id') - ->limit($this->batchSize); - - if ($this->useSkipLocked) { - $query->lock('for update skip locked'); // pgsql/MySQL raw lock — Laravel has no skipLocked() builder method - } - - /** @var Collection $rows */ - $rows = $query->get(); - - return $rows; - } - - /** @param Collection $rows */ - private function processBatch(Collection $rows): int - { - $relayed = 0; - foreach ($rows as $row) { - try { - // The envelope is rebuilt ONLY so the seam has a traceparent to continue and a message id to name - // the span with — the downstream still receives the row's own four columns, byte for byte. - $this->tracing->traceConsume( - new EventEnvelope( - OutboxRow::asString($row->event_type), - OutboxRow::asString($row->destination), - OutboxRow::asMap($row->payload), - OutboxRow::asStringMap($row->headers), - OutboxRow::asString($row->id), - ), - fn (EventEnvelope $claimed) => $this->downstream->publish( - $claimed->destination, - $claimed->eventType, - $claimed->payload, - $claimed->headers, - ), - ); - - $this->connection->table(OutboxSchema::TABLE) - ->where('id', $row->id)->where('status', OutboxSchema::STATUS_PENDING) - ->update(['status' => OutboxSchema::STATUS_PUBLISHED, 'processed_at' => $this->connection->raw('CURRENT_TIMESTAMP')]); - $relayed++; - } catch (Throwable $e) { - $attempts = OutboxRow::asInt($row->attempts) + 1; - $failed = $attempts >= $this->maxAttempts; - $this->connection->table(OutboxSchema::TABLE)->where('id', $row->id)->update([ - 'attempts' => $attempts, - 'status' => $failed ? OutboxSchema::STATUS_FAILED : OutboxSchema::STATUS_PENDING, - 'error_message' => $e->getMessage(), - 'failed_at' => $failed ? $this->connection->raw('CURRENT_TIMESTAMP') : null, - ]); - } + if (! $connection instanceof Connection) { + throw new InvalidArgumentException('OutboxRelay needs a concrete Illuminate\Database\Connection: the claim reads its driver and server version.'); } - return $relayed; + parent::__construct( + $connection, + $downstream, + OutboxClaimStrategies::configured($useSkipLocked ? OutboxClaimStrategies::AUTO : OptimisticLeaseClaim::NAME), + $batchSize, + $maxAttempts, + $tracing, + ); } } diff --git a/packages/eda-postgres/src/Outbox/OutboxRow.php b/packages/eda-postgres/src/Outbox/OutboxRow.php index f312bf5d..fc56535f 100644 --- a/packages/eda-postgres/src/Outbox/OutboxRow.php +++ b/packages/eda-postgres/src/Outbox/OutboxRow.php @@ -4,54 +4,10 @@ namespace Firefly\Eda\Postgres\Outbox; +use Firefly\Eda\Database\Outbox\OutboxRow as DatabaseOutboxRow; + /** - * Narrows the mixed-typed columns of a firefly_eda_outbox query-builder row (stdClass properties carry no static - * type) to the concrete scalar/array shapes the terminal consumer and the relay need — one place, level-max clean, - * reflection-free. asMap()/asStringMap() keep JSON_THROW_ON_ERROR so a corrupt payload/headers column fails loud - * (caught by the relay's per-row try/catch, or surfaced by the consumer poll) rather than being silently dropped. + * @deprecated since W1 — the outbox moved to firefly/eda-database; use Firefly\Eda\Database\Outbox\OutboxRow. + * Kept for one release so code that names the old class keeps compiling. */ -final class OutboxRow -{ - public static function asString(mixed $value): string - { - return is_scalar($value) ? (string) $value : ''; - } - - public static function asInt(mixed $value): int - { - return is_numeric($value) ? (int) $value : 0; - } - - /** @return array */ - public static function asMap(mixed $json): array - { - if (! is_string($json)) { - return []; - } - - $decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR); - if (! is_array($decoded)) { - return []; - } - - $map = []; - foreach ($decoded as $key => $value) { - $map[(string) $key] = $value; - } - - return $map; - } - - /** @return array */ - public static function asStringMap(mixed $json): array - { - $map = []; - foreach (self::asMap($json) as $key => $value) { - if (is_scalar($value)) { - $map[$key] = (string) $value; - } - } - - return $map; - } -} +final class OutboxRow extends DatabaseOutboxRow {} diff --git a/packages/eda-postgres/src/Outbox/OutboxSchema.php b/packages/eda-postgres/src/Outbox/OutboxSchema.php index 141c2a15..0140a7df 100644 --- a/packages/eda-postgres/src/Outbox/OutboxSchema.php +++ b/packages/eda-postgres/src/Outbox/OutboxSchema.php @@ -4,36 +4,10 @@ namespace Firefly\Eda\Postgres\Outbox; -use Illuminate\Database\Schema\Blueprint; +use Firefly\Eda\Database\Outbox\OutboxSchema as DatabaseOutboxSchema; -/** The ONE definition of the outbox table shape — shared by the migration, the publisher, the relay, and the tests. */ -final class OutboxSchema -{ - public const string TABLE = 'firefly_eda_outbox'; - - public const string STATUS_PENDING = 'PENDING'; - - public const string STATUS_PUBLISHED = 'PUBLISHED'; - - public const string STATUS_FAILED = 'FAILED'; - - public static function blueprint(Blueprint $table): void - { - $table->bigIncrements('id'); - $table->string('destination'); - $table->string('channel', 63); - $table->string('event_type'); - $table->json('payload'); - $table->json('headers'); - $table->string('transaction_id')->nullable(); - $table->string('status')->default(self::STATUS_PENDING); - $table->unsignedInteger('attempts')->default(0); - $table->text('error_message')->nullable(); - $table->timestamp('created_at')->useCurrent(); - $table->timestamp('processed_at')->nullable(); - $table->timestamp('failed_at')->nullable(); - - $table->index(['status', 'created_at'], 'firefly_eda_outbox_pending_idx'); - $table->index(['channel', 'status'], 'firefly_eda_outbox_channel_status_idx'); - } -} +/** + * @deprecated since W1 — the outbox moved to firefly/eda-database; use Firefly\Eda\Database\Outbox\OutboxSchema. + * Kept for one release so code that names the old class keeps compiling. + */ +final class OutboxSchema extends DatabaseOutboxSchema {} diff --git a/packages/eda-postgres/src/Outbox/RelayDownstream.php b/packages/eda-postgres/src/Outbox/RelayDownstream.php index 5b10f265..741ddb6f 100644 --- a/packages/eda-postgres/src/Outbox/RelayDownstream.php +++ b/packages/eda-postgres/src/Outbox/RelayDownstream.php @@ -4,287 +4,10 @@ namespace Firefly\Eda\Postgres\Outbox; -use Firefly\Config\Config; -use Firefly\Eda\EventPublisher; -use Firefly\Eda\Postgres\PostgresEventPublisher; -use Firefly\Eda\Tracing\BrokerTracing; -use Firefly\Kernel\Exception\Framework\ConfigurationException; -use Illuminate\Contracts\Container\BindingResolutionException; -use Illuminate\Contracts\Container\Container; +use Firefly\Eda\Database\Outbox\RelayDownstream as DatabaseRelayDownstream; /** - * Turns firefly.eda.postgres.relay.downstream_provider into an ACTUAL downstream EventPublisher for - * firefly:outbox:relay — the selection step that was missing entirely. - * - * WHAT WAS BROKEN. OutboxRelayCommand read the key purely as an on/off flag and then did - * `$container->make(EventPublisher::class)`. But firefly:outbox:relay only exists under - * firefly.eda.provider=postgres, and under that provider PostgresOutboxAutoConfiguration binds EventPublisher to - * the outbox WRITER. So the command's own guard (and OutboxRelay's constructor guard behind it) rejected the only - * thing it could ever resolve: relaying rows through the writer would INSERT them straight back into - * firefly_eda_outbox as PENDING — an infinite loop that grows the table forever. The result was a command with two - * reachable outcomes and no third: "no downstream configured, no-op, exit 0" or "the resolved publisher is the - * outbox writer, exit 1". Setting the documented config key could not produce a working relay under any - * configuration; it merely swapped a silent no-op for a loud one. - * - * HOW IT RESOLVES NOW, in order, so an app can always win: - * 1. an explicit container binding under self::BINDING — the escape hatch for a downstream that needs credentials, - * TLS options or anything else this package has no business knowing about; - * 2. the configured value read as a container id — an app that has BOUND it has already constructed it, so every - * constructor argument is already the app's own choice; - * 3. a SHIPPED adapter, named either by its ALIAS ('rabbitmq' | 'kafka') or by that adapter's own class-string — - * two spellings of one downstream, both built with the adapter's OWN config keys; - * 4. any other existing class-string, autowired — a publisher this package ships no adapter entry for and knows - * no configuration keys of. - * - * WHY THE ADAPTER CLASSES ARE REFERENCED AS STRINGS. deptrac's layer graph forbids an EdaPostgres -> EdaRabbitmq / - * EdaKafka edge (see deptrac.yaml: every broker is a sibling leaf package, depended on by NONE), and rightly so — - * firefly/eda-postgres must not drag php-amqplib or ext-rdkafka into a Postgres-only install. A class-string plus a - * class_exists() guard keeps the compile-time graph clean while still producing a real, fully configured publisher - * when the operator has installed the sibling package. When they have not, self::ADAPTERS names the exact composer - * package to require. - * - * WHY THE ADAPTER'S OWN CONFIG KEYS ARE PASSED EXPLICITLY. Each broker package gates ITS publisher bean behind - * #[ConditionalOnProperty(firefly.eda.provider=)], which is false here by construction (the provider - * is postgres). Resolving the class through the container therefore autowires it with the constructor DEFAULTS, so - * an app that had carefully set firefly.eda.rabbitmq.exchange would have silently relayed to `firefly.events` - * instead — a fresh silent misconfiguration in place of the one being fixed. self::parameters() passes those values - * as named constructor overrides so the relay honours exactly the settings the adapter itself would have read. - * - * WHY VALIDATION IS RUNTIME AND NOT BOOT-TIME. Throwing from EdaPostgresBootServiceProvider::boot() was considered - * and rejected: a downstream bound in ANOTHER provider's boot() may not exist yet when ours runs, so a boot-time - * check would fail applications that are correctly configured, and it would fail every web request and every - * unrelated artisan command over a relay-only concern. The relay command is the one place the value is load-bearing, - * so that is where it is validated — immediately, before a single row is claimed, and never at the first publish. + * @deprecated since W1 — use Firefly\Eda\Database\Outbox\RelayDownstream. firefly.eda.postgres.relay.downstream_provider + * is still honoured as the legacy alias of firefly.eda.database.relay.downstream-provider. */ -final class RelayDownstream -{ - /** The config key that selects the relay's downstream. */ - public const string PROVIDER_KEY = 'firefly.eda.postgres.relay.downstream_provider'; - - /** The container id an app may bind to supply a fully constructed downstream publisher itself. */ - public const string BINDING = 'firefly.eda.relay.downstream'; - - /** - * The shipped broker adapters, by the alias an operator writes in `downstream_provider`. - * - * @var array - */ - private const ADAPTERS = [ - 'rabbitmq' => ['class' => 'Firefly\\Eda\\Rabbitmq\\RabbitMqEventPublisher', 'package' => 'firefly/eda-rabbitmq'], - 'kafka' => ['class' => 'Firefly\\Eda\\Kafka\\KafkaEventPublisher', 'package' => 'firefly/eda-kafka'], - ]; - - /** The configured downstream name, or null when the operator has not opted into the relay at all. */ - public static function configuredProvider(Config $config): ?string - { - if (! $config->has(self::PROVIDER_KEY)) { - return null; - } - - $provider = trim($config->string(self::PROVIDER_KEY, '')); - - return $provider === '' ? null : $provider; - } - - /** - * Resolve the downstream publisher, or throw a ConfigurationException that names the exact thing to change. - * Never returns the outbox writer. - */ - public static function resolve(Container $container, Config $config): EventPublisher - { - $provider = self::configuredProvider($config); - - // Route 1 is checked BEFORE the unset-key failure, not after: an app that has bound a ready-made downstream - // has already named it, and demanding a redundant downstream_provider alongside the binding would reject a - // configuration this class's own error message (and the README) tell operators to use. - if ($container->bound(self::BINDING)) { - return self::guard($provider ?? self::BINDING, self::make($container, self::BINDING, [], $provider ?? self::BINDING)); - } - - if ($provider === null) { - throw new ConfigurationException(sprintf( - '%s is not set, so firefly:outbox:relay has no downstream broker to forward to. Set it to one of [%s], ' - .'to the class-string of an EventPublisher, or bind your own under the container id "%s". If you did not ' - .'mean to front a second broker at all, do not run the relay: firefly.eda.provider=postgres already ' - .'delivers committed outbox rows in-process via `php artisan firefly:eda:consume`.', - self::PROVIDER_KEY, - implode('|', array_keys(self::ADAPTERS)), - self::BINDING, - )); - } - - return self::guard($provider, self::instantiate($container, $config, $provider)); - } - - /** - * Walk the remaining resolution routes in order (route 1, the explicit binding, is handled by resolve() so it - * can win even with the key unset). Anything the container cannot build is reported with the - * BindingResolutionException attached, because "which constructor argument could not be satisfied" is the only - * detail that makes an autowiring failure actionable. - */ - private static function instantiate(Container $container, Config $config, string $provider): mixed - { - // An app that BOUND this id built the publisher itself, so every argument — tracing included — is already - // its own choice, and passing constructor overrides here would defeat the binding rather than honour it - // (Illuminate skips a shared instance the moment make() is handed parameters). - if ($container->bound($provider)) { - return self::make($container, $provider, [], $provider); - } - - // ONE route for a shipped adapter, whichever way it was spelled. The class-string used to fall straight - // through to the bare class_exists() branch below, and the publisher was then built by container autowiring - // with its constructor DEFAULTS: the exchange an operator had set in firefly.eda.rabbitmq.exchange silently - // became `firefly.events`, and — the reason this was a defect rather than an untidiness — - // firefly.eda.tracing.brokers.enabled FAILED OPEN, because Illuminate returns a constructor parameter's - // default only when the type is UNBOUND, and EdaTracing is bound in every app that has firefly/eda - // installed. Writing RabbitMqEventPublisher::class instead of 'rabbitmq' therefore put traceparents on a - // third party's wire with the compliance gate switched off: two spellings of one downstream that behaved - // differently, and the gate was bypassed by the more explicit of the two. - $alias = isset(self::ADAPTERS[$provider]) ? $provider : self::shippedAdapterAlias($provider); - - if ($alias !== null) { - $adapter = self::ADAPTERS[$alias]; - - if (! class_exists($adapter['class'])) { - throw new ConfigurationException(sprintf( - '%s="%s" but %s is not installed — %s could not be found. Run `composer require %s`, or bind your own ' - .'downstream EventPublisher under the container id "%s".', - self::PROVIDER_KEY, - $provider, - $adapter['package'], - $adapter['class'], - $adapter['package'], - self::BINDING, - )); - } - - return self::make($container, $adapter['class'], self::parameters($container, $config, $alias), $provider); - } - - // A publisher class this package ships no adapter entry for: it knows none of that class's config keys, so - // the container autowires it and the application owns every argument, tracing included. - if (class_exists($provider)) { - return self::make($container, $provider, [], $provider); - } - - throw new ConfigurationException(sprintf( - '%s="%s" names neither a shipped adapter [%s], nor a bound container id, nor an existing class. Fix the ' - .'value, or bind your own downstream EventPublisher under the container id "%s".', - self::PROVIDER_KEY, - $provider, - implode('|', array_keys(self::ADAPTERS)), - self::BINDING, - )); - } - - /** - * The alias a SHIPPED adapter's class-string belongs to, or null when the value names a class this package has - * no adapter entry for. `downstream_provider` accepts both spellings, and the two have to resolve to the same - * publisher with the same configuration — otherwise the class-string is a quiet way around every key - * self::parameters() carries across the adapter's own #[ConditionalOnProperty] gate. - */ - private static function shippedAdapterAlias(string $provider): ?string - { - foreach (self::ADAPTERS as $alias => $adapter) { - if ($adapter['class'] === $provider) { - return $alias; - } - } - - return null; - } - - /** - * The adapter-specific constructor overrides that carry the sibling package's OWN configuration across the - * provider gate it is standing behind. Named by constructor parameter — which PHP 8 named arguments already - * make part of those classes' public API — so a rename there surfaces here as a loud BindingResolutionException - * rather than a quietly-defaulted broker address. - * - * `tracing` IS ONE OF THOSE OVERRIDES, and for the same reason as `exchange`. The adapter's own bean method - * reads firefly.eda.tracing.brokers.enabled before handing the publisher an EdaTracing; that bean is gated off - * here by construction, so the publisher is built by container autowiring instead — and Illuminate returns a - * constructor parameter's DEFAULT only when the class is UNBOUND. EdaTracing is bound in every app that has - * firefly/eda installed, so `?EdaTracing $tracing = null` was being satisfied with the real tracing whatever - * the key said, and `firefly:outbox:relay` wrote traceparents onto the downstream broker with the key false — - * the one deployment the key exists for. BrokerTracing::forContainer() makes the same decision the bean - * methods make, so the gate holds on every construction path. - * - * @return array - */ - private static function parameters(Container $container, Config $config, string $provider): array - { - if ($provider === 'rabbitmq') { - return [ - 'exchange' => $config->string('firefly.eda.rabbitmq.exchange', 'firefly.events'), - 'tracing' => BrokerTracing::forContainer($container, $config), - ]; - } - - if ($provider === 'kafka') { - // The broker list lives one level down, on KafkaProducerFactory, so it is built here and injected as the - // publisher's `factory` argument — parameter overrides do not reach nested dependencies. - return [ - 'factory' => self::make( - $container, - 'Firefly\\Eda\\Kafka\\KafkaProducerFactory', - ['brokers' => $config->string('firefly.eda.kafka.brokers', '127.0.0.1:9092')], - $provider, - ), - 'tracing' => BrokerTracing::forContainer($container, $config), - ]; - } - - return []; - } - - /** - * @param array $parameters - */ - private static function make(Container $container, string $id, array $parameters, string $provider): mixed - { - try { - return $container->make($id, $parameters); - } catch (BindingResolutionException $e) { - throw new ConfigurationException(sprintf( - '%s="%s" resolved to "%s", which could not be constructed: %s. Bind a ready-made downstream ' - .'EventPublisher under the container id "%s" instead.', - self::PROVIDER_KEY, - $provider, - $id, - $e->getMessage(), - self::BINDING, - ), previous: $e); - } - } - - /** - * The last line of defence, and the reason OutboxRelay's own constructor guard is not enough on its own: a clear - * message at CONFIGURATION time beats a LogicException from deep inside the relay. Refusing the outbox writer - * here is what keeps `downstream_provider` from ever pointing the relay back at the table it is draining. - */ - private static function guard(string $provider, mixed $downstream): EventPublisher - { - if ($downstream instanceof PostgresEventPublisher) { - throw new ConfigurationException(sprintf( - '%s="%s" resolved to the Postgres outbox writer itself. The relay would re-INSERT every claimed row ' - .'into firefly_eda_outbox as PENDING and claim it again forever. Point it at a DISTINCT broker, or bind ' - .'one under the container id "%s".', - self::PROVIDER_KEY, - $provider, - self::BINDING, - )); - } - - if (! $downstream instanceof EventPublisher) { - throw new ConfigurationException(sprintf( - '%s="%s" resolved to %s, which does not implement %s.', - self::PROVIDER_KEY, - $provider, - get_debug_type($downstream), - EventPublisher::class, - )); - } - - return $downstream; - } -} +final class RelayDownstream extends DatabaseRelayDownstream {} diff --git a/packages/eda-postgres/src/PostgresEventConsumer.php b/packages/eda-postgres/src/PostgresEventConsumer.php index aff03374..2b2a9122 100644 --- a/packages/eda-postgres/src/PostgresEventConsumer.php +++ b/packages/eda-postgres/src/PostgresEventConsumer.php @@ -4,49 +4,49 @@ namespace Firefly\Eda\Postgres; -use Firefly\Eda\Consumer\EventConsumer; -use Firefly\Eda\Consumer\ReceivedEnvelope; -use Firefly\Eda\EventEnvelope; -use Firefly\Eda\Postgres\Outbox\OutboxRow; -use Firefly\Eda\Postgres\Outbox\OutboxSchema; +use Closure; +use Firefly\Eda\Database\DatabaseEventConsumer; +use Firefly\Eda\Database\Outbox\Claim\OutboxClaimStrategy; use Firefly\Eda\Postgres\Pgsql\NotificationWaiter; use Illuminate\Database\Connection; /** - * The TERMINAL in-process delivery path (B1): drives #[EventListener] handlers from committed outbox rows with NO - * EventPublisher call, so there is no re-INSERT / self-reference. subscribe() issues LISTEN ; poll() waits on - * a NOTIFY (NotificationWaiter, pgsql only — the writer's in-tx pg_notify fires on commit) up to $timeoutMs, then - * CLAIMS the oldest status='PENDING' row (FOR UPDATE SKIP LOCKED on pgsql; plain ORDER BY id on sqlite) and returns it - * as an envelope (a poll fallback also catches rows missed while offline). ConsumerLoop delivers it to the - * SubscriberRegistry and then ack()s -> the row is marked PUBLISHED/processed_at (guarded WHERE status='PENDING', so - * idempotent); on a handler throw ConsumerLoop nack()s -> attempts+1, or FAILED/failed_at past max_attempts. This is a - * DURABLE PENDING->PUBLISHED status window (M3), NOT an in-memory high-water mark: a restart resumes at the oldest - * still-PENDING row and never replays PUBLISHED rows. SINGLE consumer worker gives exactly-once in-process delivery; - * multi-worker degrades to at-least-once (idempotent handlers documented). Requires the concrete Illuminate\Database\ - * Connection because getPdo()/getDriverName() are not on ConnectionInterface (B2). + * firefly/eda-database's DatabaseEventConsumer — the same leased claim, the same guarded ack/nack, the same + * "no EventPublisher call" terminal path — with Postgres's low-latency wake-up: subscribe() issues `LISTEN ` + * and the wait between empty claims is a NOTIFY wait (NotificationWaiter) instead of a sleep, so the in-transaction + * pg_notify PostgresEventPublisher fires on commit wakes the consumer the moment the row is visible. On any other + * driver (sqlite in tests) there is no LISTEN and no wait. The claim itself is chosen by capability like everywhere + * else — on pgsql that is SKIP LOCKED inside a short claim transaction, which is what makes several workers safe. */ -final class PostgresEventConsumer implements EventConsumer +final class PostgresEventConsumer extends DatabaseEventConsumer { - /** @var \Closure(int): void */ - private readonly \Closure $awaitNotification; - /** - * @param (\Closure(int): void)|null $awaitNotification Test seam: overrides the pgsql NOTIFY wait. Defaults to the - * real Postgres LISTEN/NOTIFY wait (a pure low-latency optimization — poll() always falls through to the - * durable PENDING-row claim, and guards this against ANY failure, so a deprecation-to-exception handler on the - * deprecated pgsqlGetNotify() path can never crash the poll loop). + * @param (Closure(int): void)|null $awaitNotification overrides the pgsql NOTIFY wait (a test seam) + * @param (Closure(): \DateTimeImmutable)|null $clock */ public function __construct( - private readonly Connection $connection, + Connection $connection, private readonly string $channel = 'firefly_eda_events', - private readonly int $maxAttempts = 3, - ?\Closure $awaitNotification = null, + int $maxAttempts = 3, + ?Closure $awaitNotification = null, + ?OutboxClaimStrategy $claim = null, + float $leaseSeconds = 300.0, + ?string $claimant = null, + ?Closure $clock = null, ) { - $this->awaitNotification = $awaitNotification ?? function (int $timeoutMs): void { - if ($this->connection->getDriverName() === 'pgsql') { - NotificationWaiter::wait($this->connection->getPdo(), $timeoutMs); - } - }; + parent::__construct( + $connection, + $claim, + $maxAttempts, + $leaseSeconds, + claimant: $claimant, + awaitWork: $awaitNotification ?? static function (int $timeoutMs) use ($connection): void { + if ($connection->getDriverName() === 'pgsql') { + NotificationWaiter::wait($connection->getPdo(), $timeoutMs); + } + }, + clock: $clock, + ); } public function subscribe(array $destinations): void @@ -55,65 +55,4 @@ public function subscribe(array $destinations): void $this->connection->getPdo()->exec('LISTEN '.$this->channel); } } - - public function start(): void {} - - public function poll(int $timeoutMs): ?ReceivedEnvelope - { - try { - ($this->awaitNotification)($timeoutMs); - } catch (\Throwable) { - // NOTIFY is a low-latency optimization; on ANY failure (incl. a deprecation-to-exception handler on the - // deprecated pgsqlGetNotify path) fall through to the poll-fallback claim below, which delivers regardless. - } - - $pgsql = $this->connection->getDriverName() === 'pgsql'; - $query = $this->connection->table(OutboxSchema::TABLE) - ->where('status', OutboxSchema::STATUS_PENDING) - ->orderBy('id'); - if ($pgsql) { - $query->lock('for update skip locked'); // claim (M4) — Laravel has no skipLocked() helper - } - $row = $query->first(); - - if ($row === null) { - return null; - } - - return new ReceivedEnvelope(new EventEnvelope( - OutboxRow::asString($row->event_type), - OutboxRow::asString($row->destination), - OutboxRow::asMap($row->payload), - OutboxRow::asStringMap($row->headers), - ), OutboxRow::asInt($row->id)); - } - - public function ack(ReceivedEnvelope $received): void - { - $this->connection->table(OutboxSchema::TABLE) - ->where('id', $received->deliveryTag) - ->where('status', OutboxSchema::STATUS_PENDING) - ->update([ - 'status' => OutboxSchema::STATUS_PUBLISHED, - 'processed_at' => $this->connection->raw('CURRENT_TIMESTAMP'), - ]); - } - - public function nack(ReceivedEnvelope $received, bool $requeue = true): void - { - $row = $this->connection->table(OutboxSchema::TABLE)->where('id', $received->deliveryTag)->first(); - if ($row === null) { - return; - } - $attempts = OutboxRow::asInt($row->attempts) + 1; - $failed = $attempts >= $this->maxAttempts; - $this->connection->table(OutboxSchema::TABLE)->where('id', $received->deliveryTag)->update([ - 'attempts' => $attempts, - 'status' => $failed ? OutboxSchema::STATUS_FAILED : OutboxSchema::STATUS_PENDING, - 'error_message' => 'in-process handler failed', - 'failed_at' => $failed ? $this->connection->raw('CURRENT_TIMESTAMP') : null, - ]); - } - - public function stop(): void {} } diff --git a/packages/eda-postgres/src/PostgresEventPublisher.php b/packages/eda-postgres/src/PostgresEventPublisher.php index c8f04ebb..d1422724 100644 --- a/packages/eda-postgres/src/PostgresEventPublisher.php +++ b/packages/eda-postgres/src/PostgresEventPublisher.php @@ -5,125 +5,37 @@ namespace Firefly\Eda\Postgres; use Firefly\Eda\Bus\SubscriberRegistry; -use Firefly\Eda\EventPublisher; -use Firefly\Eda\Postgres\Outbox\OutboxSchema; +use Firefly\Eda\Database\DatabaseEventPublisher; use Firefly\Eda\Tracing\EdaTracing; -use Firefly\Eda\Tracing\NoOpEdaTracing; use Illuminate\Database\ConnectionInterface; -use stdClass; /** - * The Postgres EventPublisher: publish() INSERTs ONE firefly_eda_outbox row ON THE CURRENT CONNECTION. When called - * inside a DB transaction (which M8's #[Transactional] and the OutboxPreCommitHook guarantee for domain events), the - * INSERT enlists in that transaction and commits ATOMICALLY with the aggregate — the genuine same-tx guarantee. When - * emitNotify is true (pgsql), it also fires `SELECT pg_notify('', id)` — inside the SAME tx, so Postgres - * queues the NOTIFY and delivers it exactly when the aggregate COMMITS, waking the in-process consumer's LISTEN with - * low latency (M2). The in-process consumer (PostgresEventConsumer) claims PENDING rows and drives #[EventListener] - * handlers; the OPTIONAL firefly:outbox:relay only fronts a distinct downstream broker. start()/stop() are no-ops - * (the relay/consumer own their own connections). It uses only ConnectionInterface methods (table()/statement()/raw()) - * — no getPdo()/getDriverName() — so ConnectionInterface is the correct ctor type; the driver-gated emitNotify flag is - * computed by the caller from the concrete Connection. Reflection-free. - * - * WHY THE REGISTRY IS A REQUIRED CONSTRUCTOR DEPENDENCY (silent-data-loss regression, CapstoneOutboxDeliveryTest): - * subscribe() USED TO BE AN EMPTY NO-OP while this very docblock claimed it "feeds the shared SubscriberRegistry". - * That mattered because EventListenerWiringPass — the boot pass that turns the app's compiled #[EventListener] - * manifest into live subscriptions — resolves the bound EventPublisher and calls subscribe() ON IT. Under - * firefly.eda.provider=postgres the bound publisher is THIS class, so every listener the app compiled was handed to - * a method that discarded it, and the SubscriberRegistry the terminal consumer delivers into stayed permanently - * empty. The failure was silent rather than loud because SubscriberRegistry::deliver() on an empty registry returns - * normally: ConsumerLoop read that as a successful delivery and called ack(), flipping the outbox row - * PENDING -> PUBLISHED, never to be claimed again. Domain events were written same-transaction exactly as promised, - * drained exactly as promised, and thrown away — no exception, no log line, nothing. Making the registry a REQUIRED - * parameter (mirroring RabbitMqEventPublisher and KafkaEventPublisher, which have always taken one) means an - * instance of this class that cannot deliver its subscriptions is no longer constructible at all. - * - * THE PRODUCER SPAN WRAPS THE INSERT. publish() runs its whole body — the row and the pg_notify — inside - * EdaTracing::tracePublish(), so the `traceparent` that span produced is written onto the row itself, and the row - * carrying it is the one that COMMITS WITH THE AGGREGATE. This is the one adapter where the producer span and the - * durable record are genuinely atomic: a broker adapter's span ends when the socket accepts the message and the - * two can still disagree, while here either both the aggregate and its traced outbox row exist or neither does. - * - * That holds for BOTH writers of an outbox row, which is the only way the sentence above is worth anything: the - * bound EventPublisher bean (application code calling publish() by hand) and OutboxPreCommitHook, which builds its - * own instance per event and is — under firefly.eda.provider=postgres, where commandEventPublisher() NoOps the - * after-commit leg — the ONLY path a DomainEvent takes. Both are handed the same gated EdaTracing by - * PostgresOutboxAutoConfiguration. - * - * Downstream of the row the trace continues two different ways. PostgresEventConsumer, the terminal in-process - * path, hands the claimed row's headers to SubscriberRegistrySink, whose traceConsume() continues whatever - * traceparent it finds. The OPTIONAL relay hands them to a downstream EventPublisher instead, so OutboxRelay - * wraps that forward in traceConsume() itself — otherwise the downstream adapter's own producer span would start - * a new root and overwrite the row's traceparent. Either way the trace runs unbroken from the request that - * changed the aggregate to the listener (or broker) that reacted to it, across a commit boundary. + * The Postgres outbox writer: firefly/eda-database's DatabaseEventPublisher — the same INSERT on the caller's + * connection, the same traced closure, the same required SubscriberRegistry (see its docblock for why) — plus one + * statement. When emitNotify is true (the caller computed it from the concrete connection: pgsql), afterInsert() fires + * `SELECT pg_notify('', id)` INSIDE THE SAME TRANSACTION: Postgres queues a NOTIFY raised in a transaction and + * delivers it exactly when that transaction COMMITS, so a consumer's LISTEN wakes the moment the row becomes visible + * and never for a row that rolled back. The NOTIFY is an optimization only — the consumer's claim is the source of + * truth and also finds rows written while nobody listened. */ -final class PostgresEventPublisher implements EventPublisher +final class PostgresEventPublisher extends DatabaseEventPublisher { - private readonly EdaTracing $tracing; - public function __construct( - private readonly ConnectionInterface $connection, - private readonly SubscriberRegistry $registry, - private readonly string $channel = 'firefly_eda_events', + ConnectionInterface $connection, + SubscriberRegistry $registry, + string $channel = 'firefly_eda_events', private readonly bool $emitNotify = false, ?EdaTracing $tracing = null, ) { - $this->tracing = $tracing ?? new NoOpEdaTracing; + parent::__construct($connection, $registry, $channel, $tracing); } - /** - * Records the pattern on the SHARED SubscriberRegistry — the same singleton PostgresOutboxAutoConfiguration - * hands to this publisher and that firefly:eda:consume resolves as the ConsumerLoop's sink. The publisher itself - * never invokes handlers: delivery is the terminal consumer's job, driven off committed rows. - */ - public function subscribe(string $eventTypePattern, callable $handler): void + protected function afterInsert(int $id): void { - $this->registry->subscribe($eventTypePattern, $handler); + if ($this->emitNotify) { + // In-tx pg_notify: Postgres queues it and delivers on COMMIT, so the consumer's LISTEN wakes exactly when + // the PENDING row becomes visible. (sqlite and every other driver: emitNotify=false.) + $this->connection->statement('SELECT pg_notify(?, ?)', [$this->channel, (string) $id]); + } } - - /** - * Routed through EdaTracing::tracePublish() — the seam InMemoryEventBus and QueueEventBus have always - * used, and the reason it takes the headers rather than returning them: propagation means WRITING - * something on the envelope, so the implementation hands the send closure the headers to carry and the - * adapter builds the row from those. Before this, the three broker adapters built their envelopes - * themselves and no producer span existed on the wire at all; their CONSUME side was already traced - * through SubscriberRegistrySink, so a `traceparent` a foreign producer put in the headers was continued - * while one of our own was never written. A trace that stops at the broker is a trace of half a system. - * - * The ENTIRE body is inside the closure, INSERT and pg_notify together, so the span covers the durable - * write rather than sitting beside it. transaction_id is still derived from the closure's - * `x-correlation-id` and never from a traceparent: the relay's idempotency key must not change just - * because a request happened to be traced. - * - * With tracing off the bound EdaTracing is the NoOp, which calls $send with the caller's headers - * unchanged, so this path is byte-for-byte what it was. - * - * @param array $payload - * @param array $headers - */ - public function publish(string $destination, string $eventType, array $payload, array $headers = []): void - { - $this->tracing->tracePublish($destination, $eventType, $headers, function (array $headers) use ($destination, $eventType, $payload): void { - $id = $this->connection->table(OutboxSchema::TABLE)->insertGetId([ - 'destination' => $destination, - 'channel' => $this->channel, - 'event_type' => $eventType, - 'payload' => json_encode($payload, JSON_THROW_ON_ERROR), - 'headers' => json_encode($headers === [] ? new stdClass : $headers, JSON_THROW_ON_ERROR), - 'transaction_id' => $headers['x-correlation-id'] ?? null, - 'status' => OutboxSchema::STATUS_PENDING, - 'attempts' => 0, - 'created_at' => $this->connection->raw('CURRENT_TIMESTAMP'), - ]); - - if ($this->emitNotify) { - // In-tx pg_notify: Postgres queues it and delivers on COMMIT, so the consumer's LISTEN wakes exactly - // when the PENDING row becomes visible. statement() is on ConnectionInterface. (sqlite: emitNotify=false.) - $this->connection->statement('SELECT pg_notify(?, ?)', [$this->channel, (string) $id]); - } - }); - } - - public function start(): void {} - - public function stop(): void {} } diff --git a/packages/eda-postgres/src/PostgresOutboxAutoConfiguration.php b/packages/eda-postgres/src/PostgresOutboxAutoConfiguration.php index 5ca4208a..a10f3682 100644 --- a/packages/eda-postgres/src/PostgresOutboxAutoConfiguration.php +++ b/packages/eda-postgres/src/PostgresOutboxAutoConfiguration.php @@ -18,6 +18,8 @@ use Firefly\Data\Domain\DomainEventDispatcher; use Firefly\Eda\Bus\SubscriberRegistry; use Firefly\Eda\Consumer\EventConsumer; +use Firefly\Eda\Database\DatabaseOutboxSettings; +use Firefly\Eda\Database\Outbox\Claim\OutboxClaimStrategies; use Firefly\Eda\EventPublisher; use Firefly\Eda\Postgres\Outbox\OutboxPreCommitHook; use Firefly\Eda\Tracing\BrokerTracing; @@ -26,8 +28,15 @@ use Illuminate\Database\ConnectionResolverInterface; /** - * Wires the GENUINE same-transaction Postgres outbox when firefly.eda.provider=postgres. #[Order(900)] (below - * DataAutoConfiguration's / CqrsAutoConfiguration's 1000) makes this register FIRST, so: + * Wires the GENUINE same-transaction Postgres outbox when firefly.eda.provider=postgres. + * + * FROM W1 ON this is firefly/eda-database's outbox — the same leased claim, the same settings object + * (DatabaseOutboxSettings: the firefly.eda.database.* keys, with the firefly.eda.postgres.* keys as aliases) — plus + * the two Postgres optimizations: PostgresEventPublisher's in-transaction pg_notify and PostgresEventConsumer's LISTEN + * wake-up. DatabaseOutboxAutoConfiguration wires the same building blocks for provider=database; the two never both + * apply. + * + * #[Order(900)] (below DataAutoConfiguration's / CqrsAutoConfiguration's 1000) makes this register FIRST, so: * - eventPublisher() binds EventPublisher -> PostgresEventPublisher (the outbox writer), * - outboxPreCommitHook() binds the in-tx hook (T3's PreCommitEventHook), * - domainEventDispatcher() binds OUR DomainEventDispatcher carrying that hook, winning @@ -63,15 +72,15 @@ public function subscriberRegistry(): SubscriberRegistry #[ConditionalOnProperty(name: 'firefly.eda.provider', havingValue: 'postgres')] public function eventPublisher(Config $config, ConnectionResolverInterface $connections, SubscriberRegistry $registry, ?EdaTracing $tracing = null): EventPublisher { - $name = $config->has('firefly.eda.postgres.connection') ? $config->string('firefly.eda.postgres.connection') : null; - $conn = $connections->connection($name); + $settings = DatabaseOutboxSettings::from($config); + $conn = $connections->connection($settings->connection); // Narrow to the concrete Connection ONLY to read the driver (getDriverName() is not on ConnectionInterface — B2). $emitNotify = $conn instanceof Connection && $conn->getDriverName() === 'pgsql'; return new PostgresEventPublisher( $conn, $registry, - $config->string('firefly.eda.postgres.channel', 'firefly_eda_events'), + $settings->channel, $emitNotify, BrokerTracing::resolve($config, $tracing), ); @@ -96,7 +105,7 @@ public function outboxPreCommitHook(ConnectionResolverInterface $connections, Co return new OutboxPreCommitHook( $connections, $registry, - $config->string('firefly.eda.postgres.channel', 'firefly_eda_events'), + DatabaseOutboxSettings::from($config)->channel, $config->string('firefly.cqrs.default_destination', 'cqrs.events'), $manifest->destinations(), $correlation, @@ -122,23 +131,25 @@ public function commandEventPublisher(): CommandEventPublisher /** * The TERMINAL in-process consumer firefly:eda:consume drives in postgres mode: it claims committed PENDING - * outbox rows (LISTEN/NOTIFY wake + FOR UPDATE SKIP LOCKED), delivers each to the #[EventListener] handlers via + * outbox rows (LISTEN/NOTIFY wake + a leased claim chosen by server capability — SKIP LOCKED on pgsql), delivers each to the #[EventListener] handlers via * the SubscriberRegistry, and marks them PUBLISHED — with NO EventPublisher call, so there is no re-INSERT (B1). - * Narrows the resolved connection to the concrete type because PostgresEventConsumer needs getPdo()/getDriverName() - * (not on ConnectionInterface — B2). + * DatabaseOutboxSettings::connectionFrom() narrows the resolved connection to the concrete type because + * PostgresEventConsumer needs getPdo()/getDriverName() (not on ConnectionInterface — B2), and refuses anything else + * with a ConfigurationException instead of an assert that production builds compile away. */ #[Bean] #[ConditionalOnProperty(name: 'firefly.eda.provider', havingValue: 'postgres')] public function eventConsumer(Config $config, ConnectionResolverInterface $connections): EventConsumer { - $name = $config->has('firefly.eda.postgres.connection') ? $config->string('firefly.eda.postgres.connection') : null; - $conn = $connections->connection($name); - assert($conn instanceof Connection); // PostgresEventConsumer needs getPdo()/getDriverName() (B2) + $settings = DatabaseOutboxSettings::from($config); return new PostgresEventConsumer( - $conn, - $config->string('firefly.eda.postgres.channel', 'firefly_eda_events'), - $config->int('firefly.eda.postgres.max_attempts', 3), + $settings->connectionFrom($connections), + $settings->channel, + $settings->maxAttempts, + null, + OutboxClaimStrategies::configured($settings->claimStrategy), + $settings->leaseSeconds, ); } } diff --git a/packages/eda-postgres/tests/Integration/PostgresOutboxRoundTripTest.php b/packages/eda-postgres/tests/Integration/PostgresOutboxRoundTripTest.php index d6c594db..302afc42 100644 --- a/packages/eda-postgres/tests/Integration/PostgresOutboxRoundTripTest.php +++ b/packages/eda-postgres/tests/Integration/PostgresOutboxRoundTripTest.php @@ -12,6 +12,7 @@ use Firefly\Eda\Postgres\PostgresEventConsumer; use Firefly\Eda\Postgres\PostgresEventPublisher; use Firefly\Eda\Postgres\Tests\Fixtures\SpyDownstreamPublisher; +use Firefly\Testing\Integration\IntegrationDsn; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; @@ -32,24 +33,11 @@ * Every payload/headers assertion decodes the jsonb/json column and compares the MAP (never a raw-string equality, * which pgsql key/whitespace normalisation would false-fail). * - * @return array a Laravel pgsql connection config parsed from FIREFLY_PG_DSN + * @return array */ function outbox_pg_config(): array { - $parts = []; - foreach (explode(';', (string) getenv('FIREFLY_PG_DSN')) as $kv) { - [$key, $value] = array_pad(explode('=', $kv, 2), 2, ''); - $parts[$key] = $value; - } - - return [ - 'driver' => 'pgsql', - 'host' => $parts['host'] ?? '127.0.0.1', - 'port' => $parts['port'] ?? '5432', - 'database' => $parts['dbname'] ?? 'postgres', - 'username' => $parts['user'] ?? 'postgres', - 'password' => $parts['password'] ?? '', - ]; + return IntegrationDsn::connectionConfig('pgsql'); } /** Decode a json/jsonb column value to a comparable map (jsonb-aware — never a raw-string compare). */ @@ -171,6 +159,6 @@ function outbox_decode(mixed $column): mixed $writer->disconnect(); $consumerConn->disconnect(); })->skip( - ! extension_loaded('pdo_pgsql') || getenv('FIREFLY_PG_DSN') === false, - 'Set FIREFLY_PG_DSN (and pdo_pgsql) to run the @group integration Postgres outbox round-trip.', + IntegrationDsn::skipReason('pgsql') !== null, + (string) IntegrationDsn::skipReason('pgsql'), )->group('integration'); diff --git a/packages/eda-postgres/tests/PostgresReadsDatabaseSettingsTest.php b/packages/eda-postgres/tests/PostgresReadsDatabaseSettingsTest.php new file mode 100644 index 00000000..0d4cfffb --- /dev/null +++ b/packages/eda-postgres/tests/PostgresReadsDatabaseSettingsTest.php @@ -0,0 +1,35 @@ +app(); + $app->make(EventPublisher::class)->publish('users', 'user.created', ['id' => 1]); + + /** @var EventConsumer $consumer */ + $consumer = $app->make(EventConsumer::class); + (new ConsumerLoop)->run($consumer, function (): void { + throw new RuntimeException('handler boom'); + }, new ConsumerOptions(maxMessages: 1, pollTimeoutMs: 10)); + + $row = DB::table(OutboxSchema::TABLE)->first(); + + // max-attempts=1: the first failure is final; the row carries the database channel. + expect($consumer)->toBeInstanceOf(PostgresEventConsumer::class) + ->and($consumer)->toBeInstanceOf(DatabaseEventConsumer::class) + ->and($row?->status)->toBe(OutboxSchema::STATUS_FAILED) + ->and($row?->channel)->toBe('orders_channel'); +}); diff --git a/packages/eda-postgres/tests/Support/PostgresSettingsCapstoneTestCase.php b/packages/eda-postgres/tests/Support/PostgresSettingsCapstoneTestCase.php new file mode 100644 index 00000000..0310fceb --- /dev/null +++ b/packages/eda-postgres/tests/Support/PostgresSettingsCapstoneTestCase.php @@ -0,0 +1,22 @@ + */ + protected function configOverrides(): array + { + return [ + ...parent::configOverrides(), + 'firefly.eda.database.max-attempts' => 1, + 'firefly.eda.database.channel' => 'orders_channel', + ]; + } +} diff --git a/packages/eda-rabbitmq/tests/Integration/RabbitMqRoundTripTest.php b/packages/eda-rabbitmq/tests/Integration/RabbitMqRoundTripTest.php index b7e0fdec..98c433b4 100644 --- a/packages/eda-rabbitmq/tests/Integration/RabbitMqRoundTripTest.php +++ b/packages/eda-rabbitmq/tests/Integration/RabbitMqRoundTripTest.php @@ -12,33 +12,16 @@ use Firefly\Eda\Rabbitmq\RabbitMqEventConsumer; use Firefly\Eda\Rabbitmq\RabbitMqEventPublisher; use Firefly\Eda\Rabbitmq\Tests\Support\RabbitMqIntegrationTestCase; +use Firefly\Testing\Integration\IntegrationDsn; use Illuminate\Config\Repository; use PhpAmqpLib\Message\AMQPMessage; uses(RabbitMqIntegrationTestCase::class); -/** @return Config a Config over firefly.eda.rabbitmq.* parsed from FIREFLY_RABBITMQ_DSN */ +/** @return Config a Config over firefly.eda.rabbitmq.* from FIREFLY_RABBITMQ_DSN (key=value or amqp:// URL form) */ function rabbitTestConfig(): Config { - $parts = []; - foreach (explode(';', (string) getenv('FIREFLY_RABBITMQ_DSN')) as $kv) { - [$key, $value] = array_pad(explode('=', $kv, 2), 2, ''); - $parts[$key] = $value; - } - - return new Config(new Repository([ - 'firefly' => [ - 'eda' => [ - 'rabbitmq' => [ - 'host' => $parts['host'] ?? '127.0.0.1', - 'port' => (int) ($parts['port'] ?? 5672), - 'user' => $parts['user'] ?? 'guest', - 'password' => $parts['password'] ?? 'guest', - 'vhost' => '/', - ], - ], - ], - ])); + return new Config(new Repository(['firefly' => ['eda' => ['rabbitmq' => IntegrationDsn::rabbitmq()]]])); } it('round-trips publish -> consume -> handler -> ack against a real RabbitMQ', function () { @@ -71,7 +54,7 @@ function rabbitTestConfig(): Config ); expect($processed)->toBe(1)->and($received)->toBe([42]); -})->skip(getenv('FIREFLY_RABBITMQ_DSN') === false, 'Set FIREFLY_RABBITMQ_DSN to run the @group integration RabbitMQ round-trip.')->group('integration'); +})->skip(IntegrationDsn::missing(IntegrationDsn::RABBITMQ), 'Set FIREFLY_RABBITMQ_DSN to run the @group integration RabbitMQ round-trip.')->group('integration'); it('routes an exhausted retry to the DLX queue', function () { $config = rabbitTestConfig(); @@ -141,4 +124,4 @@ function rabbitTestConfig(): Config } expect($decoded['payload']['id'] ?? null)->toBe(99); -})->skip(getenv('FIREFLY_RABBITMQ_DSN') === false, 'Set FIREFLY_RABBITMQ_DSN to run the @group integration RabbitMQ round-trip.')->group('integration'); +})->skip(IntegrationDsn::missing(IntegrationDsn::RABBITMQ), 'Set FIREFLY_RABBITMQ_DSN to run the @group integration RabbitMQ round-trip.')->group('integration'); diff --git a/packages/eda/README.md b/packages/eda/README.md index 6a4df783..affceb1f 100644 --- a/packages/eda/README.md +++ b/packages/eda/README.md @@ -3,7 +3,8 @@ LaraFly's event-driven-architecture transport: an `EventPublisher` broker-bus port with an in-memory default adapter and a Laravel-queue async adapter, an `EventEnvelope` + JSON `Serializer` seam, a `#[EventListener]` attribute compiled into an `EventListenerManifest` and wired onto the bus at boot, -and a linear-backoff retry / dead-letter helper. Real brokers (Kafka/RabbitMQ/Postgres) and the durable -transactional outbox ship as their own packages at SP-4. +and a linear-backoff retry / dead-letter helper. Durable dead letters (`firefly.eda.dead-letter.store=database`) +and `firefly:eda:dead-letters:replay` ship here; the same-transaction outbox is `firefly/eda-database` (and +`firefly/eda-postgres`), the brokers `firefly/eda-rabbitmq` and `firefly/eda-kafka`. Apache-2.0 © Firefly Software Solutions Inc. diff --git a/packages/eda/cache/firefly-eda-components.php b/packages/eda/cache/firefly-eda-components.php index c79da697..3e6b6322 100644 --- a/packages/eda/cache/firefly-eda-components.php +++ b/packages/eda/cache/firefly-eda-components.php @@ -51,6 +51,7 @@ 'order' => 0, 'lazy' => false, 'dependencies' => [ + 0 => 'Illuminate\\Container\\Container', ], ], 3 => [ diff --git a/packages/eda/composer.json b/packages/eda/composer.json index 2a49fc82..f936c1bc 100644 --- a/packages/eda/composer.json +++ b/packages/eda/composer.json @@ -30,6 +30,7 @@ "illuminate/console": "^13.0", "illuminate/container": "^13.0", "illuminate/contracts": "^13.0", + "illuminate/database": "^13.0", "illuminate/queue": "^13.0", "illuminate/support": "^13.0" }, diff --git a/packages/eda/database/migrations/2026_10_12_000200_create_firefly_eda_dead_letters_table.php b/packages/eda/database/migrations/2026_10_12_000200_create_firefly_eda_dead_letters_table.php new file mode 100644 index 00000000..f1ce7fb7 --- /dev/null +++ b/packages/eda/database/migrations/2026_10_12_000200_create_firefly_eda_dead_letters_table.php @@ -0,0 +1,21 @@ + DeadLetterSchema::blueprint($table)); + } + + public function down(): void + { + Schema::dropIfExists(DeadLetterSchema::TABLE); + } +}; diff --git a/packages/eda/src/Console/ReplayDeadLettersCommand.php b/packages/eda/src/Console/ReplayDeadLettersCommand.php new file mode 100644 index 00000000..77425b71 --- /dev/null +++ b/packages/eda/src/Console/ReplayDeadLettersCommand.php @@ -0,0 +1,92 @@ +` is added, so a listener can tell a replay from a first delivery. Each entry that + * republished is removed unless --keep; one that fails is reported and kept, and the command exits FAILURE. Replay is + * at-least-once: a crash between the publish and the removal replays that entry again next time. + */ +final class ReplayDeadLettersCommand extends Command +{ + /** @var string */ + protected $signature = 'firefly:eda:dead-letters:replay {--id=* : replay only these entries} {--destination= : only entries dead-lettered from this destination} {--event-type= : only entries of this event type} {--limit=100 : at most this many entries} {--dry-run : list what would be replayed and change nothing} {--keep : keep each entry after it is republished}'; + + /** @var string */ + protected $description = 'Republish dead-lettered eda envelopes through the bound EventPublisher, removing each one that republished.'; + + public function handle(DeadLetterStore $store, EventPublisher $publisher): int + { + if (! $store instanceof ReplayableDeadLetterStore) { + $this->error(sprintf('The bound DeadLetterStore (%s) cannot select or remove entries. Bind a ReplayableDeadLetterStore, or drop the binding and set firefly.eda.dead-letter.store=database.', get_debug_type($store))); + + return self::FAILURE; + } + + $entries = $store->find($this->query()); + + $replayed = 0; + $failed = 0; + foreach ($entries as $entry) { + $envelope = $entry->envelope; + $destination = $envelope->headers['x-original-topic'] ?? $envelope->destination; + $headers = array_diff_key($envelope->headers, ['x-original-topic' => true, 'x-exception' => true]); + $headers['x-replayed-from'] = (string) $entry->id; + + if ($this->option('dry-run') === true) { + $this->line(sprintf('would replay %s %s -> %s', $entry->id, $envelope->eventType, $destination)); + + continue; + } + + try { + $publisher->publish($destination, $envelope->eventType, $envelope->payload, $headers); + if ($this->option('keep') !== true && $entry->id !== null) { + $store->remove($entry->id); + } + $replayed++; + $this->line(sprintf('replayed %s %s -> %s', $entry->id, $envelope->eventType, $destination)); + } catch (Throwable $e) { + $failed++; + $this->error(sprintf('could not replay %s %s: %s', $entry->id, $envelope->eventType, $e->getMessage())); + } + } + + $this->info(sprintf('firefly:eda:dead-letters:replay — %d selected, %d replayed, %d failed.', count($entries), $replayed, $failed)); + + return $failed === 0 ? self::SUCCESS : self::FAILURE; + } + + private function query(): DeadLetterQuery + { + $ids = []; + foreach ((array) $this->option('id') as $id) { + if (is_string($id) && $id !== '') { + $ids[] = $id; + } + } + + $destination = $this->option('destination'); + $eventType = $this->option('event-type'); + $limit = $this->option('limit'); + + return new DeadLetterQuery( + $ids, + is_string($destination) && $destination !== '' ? $destination : null, + is_string($eventType) && $eventType !== '' ? $eventType : null, + is_numeric($limit) ? max(1, (int) $limit) : 100, + ); + } +} diff --git a/packages/eda/src/DeadLetter/DatabaseDeadLetterStore.php b/packages/eda/src/DeadLetter/DatabaseDeadLetterStore.php new file mode 100644 index 00000000..7ca28dc9 --- /dev/null +++ b/packages/eda/src/DeadLetter/DatabaseDeadLetterStore.php @@ -0,0 +1,146 @@ +clock === null ? new DateTimeImmutable : ($this->clock)(); + + $this->db()->table(DeadLetterSchema::TABLE)->insert([ + 'id' => (string) Str::uuid7(), + 'event_id' => $envelope->eventId, + 'event_type' => $envelope->eventType, + 'destination' => $envelope->destination, + 'payload' => json_encode($envelope->payload === [] ? new stdClass : $envelope->payload, JSON_THROW_ON_ERROR), + 'headers' => json_encode($envelope->headers === [] ? new stdClass : $envelope->headers, JSON_THROW_ON_ERROR), + 'exception_class' => $cause::class, + 'exception_message' => $cause->getMessage(), + 'dead_lettered_at' => $now->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s.v'), + ]); + } + + /** + * @return list + */ + public function all(): array + { + return $this->find(new DeadLetterQuery(limit: null)); + } + + public function find(DeadLetterQuery $query): array + { + $select = $this->db()->table(DeadLetterSchema::TABLE)->orderBy('dead_lettered_at')->orderBy('id'); + + if ($query->ids !== []) { + $select->whereIn('id', $query->ids); + } + if ($query->destination !== null) { + $select->where('destination', $query->destination); + } + if ($query->eventType !== null) { + $select->where('event_type', $query->eventType); + } + if ($query->limit !== null) { + $select->limit(max(1, $query->limit)); + } + + $entries = []; + foreach ($select->get() as $row) { + $entries[] = self::entry($row); + } + + return $entries; + } + + public function remove(string $id): bool + { + return $this->db()->table(DeadLetterSchema::TABLE)->where('id', $id)->delete() === 1; + } + + private function db(): ConnectionInterface + { + return $this->connections->connection($this->connection); + } + + private static function entry(object $row): DeadLetterEntry + { + $columns = get_object_vars($row); + $eventId = self::text($columns['event_id'] ?? null); + $at = self::text($columns['dead_lettered_at'] ?? null); + + $headers = []; + foreach (self::map($columns['headers'] ?? null) as $key => $value) { + if (is_scalar($value)) { + $headers[$key] = (string) $value; + } + } + + return new DeadLetterEntry( + new EventEnvelope( + self::text($columns['event_type'] ?? null), + self::text($columns['destination'] ?? null), + self::map($columns['payload'] ?? null), + $headers, + $eventId === '' ? null : $eventId, + ), + self::text($columns['exception_class'] ?? null), + self::text($columns['exception_message'] ?? null), + self::text($columns['id'] ?? null), + $at === '' ? null : new DateTimeImmutable($at, new DateTimeZone('UTC')), + ); + } + + private static function text(mixed $value): string + { + return is_scalar($value) ? (string) $value : ''; + } + + /** + * @return array + */ + private static function map(mixed $json): array + { + $decoded = is_string($json) ? json_decode($json, true, 512, JSON_THROW_ON_ERROR) : null; + + $map = []; + foreach (is_array($decoded) ? $decoded : [] as $key => $value) { + $map[(string) $key] = $value; + } + + return $map; + } +} diff --git a/packages/eda/src/DeadLetter/DeadLetterEntry.php b/packages/eda/src/DeadLetter/DeadLetterEntry.php index a131421d..1bad0c36 100644 --- a/packages/eda/src/DeadLetter/DeadLetterEntry.php +++ b/packages/eda/src/DeadLetter/DeadLetterEntry.php @@ -4,11 +4,13 @@ namespace Firefly\Eda\DeadLetter; +use DateTimeImmutable; use Firefly\Eda\EventEnvelope; /** * One dead-lettered envelope plus the exception class + message that caused it. Immutable record; the envelope - * already carries the x-original-topic / x-exception headers added by RetryingEventHandler. + * already carries the x-original-topic / x-exception headers added by RetryingEventHandler. A replayable store fills + * `id` (the handle replay and removal use) and `deadLetteredAt`; both stay null for an entry built by hand. */ final readonly class DeadLetterEntry { @@ -16,5 +18,7 @@ public function __construct( public EventEnvelope $envelope, public string $exceptionClass, public string $exceptionMessage, + public ?string $id = null, + public ?DateTimeImmutable $deadLetteredAt = null, ) {} } diff --git a/packages/eda/src/DeadLetter/DeadLetterQuery.php b/packages/eda/src/DeadLetter/DeadLetterQuery.php new file mode 100644 index 00000000..7c049516 --- /dev/null +++ b/packages/eda/src/DeadLetter/DeadLetterQuery.php @@ -0,0 +1,27 @@ + $ids empty = any + * @param int|null $limit null = all + */ + public function __construct( + public array $ids = [], + public ?string $destination = null, + public ?string $eventType = null, + public ?int $limit = 100, + ) {} + + public function matches(DeadLetterEntry $entry): bool + { + return ($this->ids === [] || in_array($entry->id, $this->ids, true)) + && ($this->destination === null || $entry->envelope->destination === $this->destination) + && ($this->eventType === null || $entry->envelope->eventType === $this->eventType); + } +} diff --git a/packages/eda/src/DeadLetter/DeadLetterSchema.php b/packages/eda/src/DeadLetter/DeadLetterSchema.php new file mode 100644 index 00000000..836d4d97 --- /dev/null +++ b/packages/eda/src/DeadLetter/DeadLetterSchema.php @@ -0,0 +1,29 @@ +string('id', 36)->primary(); + $table->string('event_id', 64)->nullable(); + $table->string('event_type'); + $table->string('destination'); + $table->json('payload'); + $table->json('headers'); + $table->string('exception_class'); + $table->text('exception_message'); + $table->dateTime('dead_lettered_at', 3); + + $table->index(['destination', 'event_type'], 'firefly_eda_dead_letters_route_idx'); + $table->index(['dead_lettered_at'], 'firefly_eda_dead_letters_at_idx'); + } +} diff --git a/packages/eda/src/DeadLetter/DeadLetterStore.php b/packages/eda/src/DeadLetter/DeadLetterStore.php index 4029fc27..9d9c4ede 100644 --- a/packages/eda/src/DeadLetter/DeadLetterStore.php +++ b/packages/eda/src/DeadLetter/DeadLetterStore.php @@ -10,7 +10,8 @@ /** * The eda dead-letter sink (pyfly eda/dlq.py EdaDeadLetterStore parity). A handler that exhausts its retries has * its envelope stored here (enriched with x-original-topic / x-exception headers), so no event is silently lost. - * InMemoryDeadLetterStore is the default; a durable adapter can land at SP-4. + * InMemoryDeadLetterStore is the default; DatabaseDeadLetterStore (firefly.eda.dead-letter.store=database) keeps + * them durably. Both implement ReplayableDeadLetterStore, which firefly:eda:dead-letters:replay needs. */ interface DeadLetterStore { diff --git a/packages/eda/src/DeadLetter/InMemoryDeadLetterStore.php b/packages/eda/src/DeadLetter/InMemoryDeadLetterStore.php index d43541df..b2e8cedb 100644 --- a/packages/eda/src/DeadLetter/InMemoryDeadLetterStore.php +++ b/packages/eda/src/DeadLetter/InMemoryDeadLetterStore.php @@ -4,20 +4,23 @@ namespace Firefly\Eda\DeadLetter; +use DateTimeImmutable; use Firefly\Eda\EventEnvelope; +use Illuminate\Support\Str; use Throwable; /** - * The default DeadLetterStore: an in-process append-only list. Zero external services (skeleton default). + * The default DeadLetterStore: an in-process append-only list. Zero external services (skeleton default) — and lost + * when the process ends; firefly.eda.dead-letter.store=database keeps dead letters durably. */ -final class InMemoryDeadLetterStore implements DeadLetterStore +final class InMemoryDeadLetterStore implements ReplayableDeadLetterStore { /** @var list */ private array $entries = []; public function store(EventEnvelope $envelope, Throwable $cause): void { - $this->entries[] = new DeadLetterEntry($envelope, $cause::class, $cause->getMessage()); + $this->entries[] = new DeadLetterEntry($envelope, $cause::class, $cause->getMessage(), (string) Str::uuid(), new DateTimeImmutable); } /** @@ -27,4 +30,24 @@ public function all(): array { return $this->entries; } + + public function find(DeadLetterQuery $query): array + { + $found = array_values(array_filter($this->entries, $query->matches(...))); + + return $query->limit === null ? $found : array_slice($found, 0, max(1, $query->limit)); + } + + public function remove(string $id): bool + { + foreach ($this->entries as $index => $entry) { + if ($entry->id === $id) { + array_splice($this->entries, $index, 1); + + return true; + } + } + + return false; + } } diff --git a/packages/eda/src/DeadLetter/ReplayableDeadLetterStore.php b/packages/eda/src/DeadLetter/ReplayableDeadLetterStore.php new file mode 100644 index 00000000..3fc5388e --- /dev/null +++ b/packages/eda/src/DeadLetter/ReplayableDeadLetterStore.php @@ -0,0 +1,20 @@ + oldest first + */ + public function find(DeadLetterQuery $query): array; + + public function remove(string $id): bool; +} diff --git a/packages/eda/src/EdaAutoConfiguration.php b/packages/eda/src/EdaAutoConfiguration.php index f491a8d9..b21e5769 100644 --- a/packages/eda/src/EdaAutoConfiguration.php +++ b/packages/eda/src/EdaAutoConfiguration.php @@ -12,18 +12,22 @@ use Firefly\Eda\Bus\InMemoryEventBus; use Firefly\Eda\Bus\QueueEventBus; use Firefly\Eda\Bus\SubscriberRegistry; +use Firefly\Eda\DeadLetter\DatabaseDeadLetterStore; use Firefly\Eda\DeadLetter\DeadLetterStore; use Firefly\Eda\DeadLetter\InMemoryDeadLetterStore; use Firefly\Eda\Exception\SerializationException; use Firefly\Eda\Tracing\EdaTracing; use Firefly\Eda\Tracing\NoOpEdaTracing; +use Firefly\Kernel\Exception\Framework\ConfigurationException; use Illuminate\Container\Container; +use Illuminate\Contracts\Container\BindingResolutionException; +use Illuminate\Database\ConnectionResolverInterface; /** * Always-on eda transport wiring. Selects the EventPublisher adapter by firefly.eda.provider (`memory`, the * default zero-services bus | `queue`, the async illuminate/queue adapter), the Serializer by * firefly.eda.serialization_format (`json` only in M9; avro/protobuf are rejected seams), and the DeadLetterStore - * default. #[Order(1000)] places it after user definitions; each #[ConditionalOnMissingBean] lets an app bind its + * (`memory` by default, `database` by firefly.eda.dead-letter.store). #[Order(1000)] places it after user definitions; each #[ConditionalOnMissingBean] lets an app bind its * own and win. Both adapters are singletons (the #[Bean] default), so the wiring pass and the queue worker share * ONE SubscriberRegistry. Mirrors SchedulingAutoConfiguration / DataAutoConfiguration. */ @@ -59,11 +63,45 @@ public function serializer(Config $config): Serializer }; } + /** + * firefly.eda.dead-letter.store: `memory` (the default — in-process, lost on exit) or `database` (durable, + * replayable, on firefly.eda.dead-letter.connection). It takes the container, not Config and the connection + * resolver, because neither is safe to inject here: Config is autowired, never bound, so Container::call() hands a + * nullable `?Config $config = null` its null default; and Laravel aliases ConnectionResolverInterface to `db` + * even in an app that never registered DatabaseServiceProvider, so injecting it would break every boot without a + * database. The resolver is made only when `database` is chosen. A direct call with no container (the unit tests) + * yields the default. + */ #[Bean] #[ConditionalOnMissingBean(DeadLetterStore::class)] - public function deadLetterStore(): DeadLetterStore + public function deadLetterStore(?Container $container = null): DeadLetterStore { - return new InMemoryDeadLetterStore; + if ($container === null) { + return new InMemoryDeadLetterStore; + } + + /** @var Config $config */ + $config = $container->make(Config::class); + $store = $config->string('firefly.eda.dead-letter.store', 'memory'); + + if ($store === 'memory') { + return new InMemoryDeadLetterStore; + } + + if ($store !== 'database') { + throw new ConfigurationException("firefly.eda.dead-letter.store='{$store}' is not one of [memory|database]."); + } + + try { + $connections = $container->make(ConnectionResolverInterface::class); + } catch (BindingResolutionException $e) { + throw new ConfigurationException('firefly.eda.dead-letter.store=database needs a database connection resolver; register Laravel\'s DatabaseServiceProvider (every application has it) or bind your own DeadLetterStore.', previous: $e); + } + + return new DatabaseDeadLetterStore( + $connections, + $config->has('firefly.eda.dead-letter.connection') ? $config->string('firefly.eda.dead-letter.connection') : null, + ); } /** diff --git a/packages/eda/src/EdaConsumerServiceProvider.php b/packages/eda/src/EdaConsumerServiceProvider.php index cccb88db..a94fd4ec 100644 --- a/packages/eda/src/EdaConsumerServiceProvider.php +++ b/packages/eda/src/EdaConsumerServiceProvider.php @@ -5,18 +5,26 @@ namespace Firefly\Eda; use Firefly\Eda\Console\ConsumeEventsCommand; +use Firefly\Eda\Console\ReplayDeadLettersCommand; use Illuminate\Support\ServiceProvider; /** - * Registers the firefly:eda:consume Artisan command via package auto-discovery. Console-guarded so nothing runs in an - * HTTP request. The command is inert until a broker package binds an EventConsumer. + * Registers firefly:eda:consume and firefly:eda:dead-letters:replay via package auto-discovery (console only, so + * nothing runs in an HTTP request), and ships the firefly_eda_dead_letters migration when + * firefly.eda.dead-letter.store=database. The consume command is inert until a broker package binds an EventConsumer. */ final class EdaConsumerServiceProvider extends ServiceProvider { + public const string MIGRATIONS = __DIR__.'/../database/migrations'; + public function boot(): void { + if ($this->app->make('config')->get('firefly.eda.dead-letter.store') === 'database') { + $this->loadMigrationsFrom(self::MIGRATIONS); + } + if ($this->app->runningInConsole()) { - $this->commands([ConsumeEventsCommand::class]); + $this->commands([ConsumeEventsCommand::class, ReplayDeadLettersCommand::class]); } } } diff --git a/packages/eda/tests/Console/ReplayDeadLettersCommandTest.php b/packages/eda/tests/Console/ReplayDeadLettersCommandTest.php new file mode 100644 index 00000000..d7dbdab3 --- /dev/null +++ b/packages/eda/tests/Console/ReplayDeadLettersCommandTest.php @@ -0,0 +1,91 @@ +app(); + $app->make(EventPublisher::class)->publish('orders', 'order.placed', ['id' => 1]); + $app->make(EventPublisher::class)->publish('orders', 'order.paid', ['id' => 1]); + + expect($app->make(DeadLetterStore::class))->toBeInstanceOf(DatabaseDeadLetterStore::class) + ->and(DB::table(DeadLetterSchema::TABLE)->count())->toBe(2); + + // Dry run: lists, changes nothing. + expect($this->replay(['--dry-run' => true]))->toBe(0) + ->and($this->replayOutput())->toContain('would replay') + ->and(DB::table(DeadLetterSchema::TABLE)->count())->toBe(2); + + // The outage is over: replay only order.placed, then the rest. + FlakyOrderListener::$failing = false; + expect($this->replay(['--event-type' => 'order.placed']))->toBe(0) + ->and(DB::table(DeadLetterSchema::TABLE)->pluck('event_type')->all())->toBe(['order.paid']) + ->and($this->replay())->toBe(0) + ->and(DB::table(DeadLetterSchema::TABLE)->count())->toBe(0); + + /** @var ListenerSpy $spy */ + $spy = $app->make(ListenerSpy::class); + expect($spy->seen)->toHaveCount(2) + ->and($spy->seen[0])->toStartWith('order.placed#') + ->and($spy->seen[0])->not->toEndWith('#first'); +}); + +it('dead-letters a replay that fails again, and --keep leaves the original in place', function () { + /** @var DeadLetterCapstoneTestCase $this */ + $this->app()->make(EventPublisher::class)->publish('orders', 'order.placed', ['id' => 2]); + + // Still failing: the republished envelope is dead-lettered again by the memory bus (the publish itself succeeds), + // and --keep leaves the original entry in place — two entries, nothing lost. + expect($this->replay(['--keep' => true]))->toBe(0) + ->and(DB::table(DeadLetterSchema::TABLE)->count())->toBe(2); +}); + +it('keeps an entry whose republish throws, and exits 1', function () { + /** @var DeadLetterCapstoneTestCase $this */ + $this->app()->make(EventPublisher::class)->publish('orders', 'order.placed', ['id' => 3]); + $this->app()->instance(EventPublisher::class, new class implements EventPublisher + { + public function subscribe(string $eventTypePattern, callable $handler): void {} + + public function publish(string $destination, string $eventType, array $payload, array $headers = []): void + { + throw new RuntimeException('broker down'); + } + + public function start(): void {} + + public function stop(): void {} + }); + + expect($this->replay())->toBe(1) + ->and($this->replayOutput())->toContain('could not replay') + ->and(DB::table(DeadLetterSchema::TABLE)->count())->toBe(1); +}); + +it('refuses a bound DeadLetterStore that cannot select or remove entries, and exits 1', function () { + /** @var DeadLetterCapstoneTestCase $this */ + $this->app()->instance(DeadLetterStore::class, new class implements DeadLetterStore + { + public function store(EventEnvelope $envelope, Throwable $cause): void {} + + public function all(): array + { + return []; + } + }); + + expect($this->replay())->toBe(1) + ->and($this->replayOutput())->toContain('cannot select or remove entries'); +}); diff --git a/packages/eda/tests/DeadLetter/DatabaseDeadLetterStoreTest.php b/packages/eda/tests/DeadLetter/DatabaseDeadLetterStoreTest.php new file mode 100644 index 00000000..8e3bb365 --- /dev/null +++ b/packages/eda/tests/DeadLetter/DatabaseDeadLetterStoreTest.php @@ -0,0 +1,82 @@ +up(); +}); + +function deadLetterDatabaseStore(string $now = '2026-10-12 10:00:00.250'): DatabaseDeadLetterStore +{ + /** @var ConnectionResolverInterface $connections */ + $connections = App::make(ConnectionResolverInterface::class); + + return new DatabaseDeadLetterStore($connections, null, static fn (): DateTimeImmutable => new DateTimeImmutable($now, new DateTimeZone('UTC'))); +} + +it('keeps a dead letter durably, envelope, cause and instant included', function () { + $envelope = new EventEnvelope('order.placed', 'orders', ['id' => 7, 'lines' => [['sku' => 'A']]], ['x-original-topic' => 'orders', 'x-exception' => 'boom'], 'evt-1'); + deadLetterDatabaseStore()->store($envelope, new LogicException('boom')); + + $entries = deadLetterDatabaseStore()->all(); + + expect(Schema::hasTable(DeadLetterSchema::TABLE))->toBeTrue() + ->and($entries)->toHaveCount(1) + ->and($entries[0]->id)->toMatch('/^[0-9a-f-]{36}$/') + ->and($entries[0]->envelope->eventType)->toBe('order.placed') + ->and($entries[0]->envelope->destination)->toBe('orders') + ->and($entries[0]->envelope->eventId)->toBe('evt-1') + ->and($entries[0]->envelope->payload)->toBe(['id' => 7, 'lines' => [['sku' => 'A']]]) + ->and($entries[0]->envelope->headers)->toBe(['x-original-topic' => 'orders', 'x-exception' => 'boom']) + ->and($entries[0]->exceptionClass)->toBe(LogicException::class) + ->and($entries[0]->exceptionMessage)->toBe('boom') + ->and($entries[0]->deadLetteredAt?->format('Y-m-d H:i:s.v'))->toBe('2026-10-12 10:00:00.250'); +}); + +it('selects by id, destination and event type, oldest first, and removes one entry', function () { + deadLetterDatabaseStore('2026-10-12 10:00:02')->store(new EventEnvelope('order.paid', 'orders'), new RuntimeException('b')); + deadLetterDatabaseStore('2026-10-12 10:00:01')->store(new EventEnvelope('order.placed', 'orders'), new RuntimeException('a')); + deadLetterDatabaseStore('2026-10-12 10:00:03')->store(new EventEnvelope('user.created', 'users'), new RuntimeException('c')); + $store = deadLetterDatabaseStore(); + + $orders = $store->find(new DeadLetterQuery(destination: 'orders')); + + expect(array_map(static fn (DeadLetterEntry $e): string => $e->envelope->eventType, $orders))->toBe(['order.placed', 'order.paid']) + ->and($store->find(new DeadLetterQuery(eventType: 'user.created')))->toHaveCount(1) + ->and($store->find(new DeadLetterQuery(limit: 2)))->toHaveCount(2) + ->and($store->find(new DeadLetterQuery(ids: [(string) $orders[1]->id]))[0]->envelope->eventType)->toBe('order.paid') + ->and($store->remove((string) $orders[0]->id))->toBeTrue() + ->and($store->remove((string) $orders[0]->id))->toBeFalse() + ->and(DB::table(DeadLetterSchema::TABLE)->count())->toBe(2); +}); + +it('keeps insertion order for dead letters written in the same millisecond, so --limit replays the oldest', function () { + $store = deadLetterDatabaseStore('2026-10-12 10:00:00.250'); + $types = array_map(static fn (int $i): string => "order.same-instant-{$i}", range(1, 40)); + foreach ($types as $type) { + $store->store(new EventEnvelope($type, 'orders'), new RuntimeException('tie')); + } + + expect(array_map(static fn (DeadLetterEntry $e): string => $e->envelope->eventType, $store->all()))->toBe($types) + ->and($store->find(new DeadLetterQuery(destination: 'orders', limit: 1))[0]->envelope->eventType)->toBe('order.same-instant-1'); +}); diff --git a/packages/eda/tests/DeadLetter/DeadLetterQueryTest.php b/packages/eda/tests/DeadLetter/DeadLetterQueryTest.php new file mode 100644 index 00000000..d740f822 --- /dev/null +++ b/packages/eda/tests/DeadLetter/DeadLetterQueryTest.php @@ -0,0 +1,27 @@ +store(new EventEnvelope('order.placed', 'orders'), new RuntimeException('a')); + $store->store(new EventEnvelope('order.paid', 'orders'), new RuntimeException('b')); + $store->store(new EventEnvelope('user.created', 'users'), new RuntimeException('c')); + + [$first] = $store->all(); + + expect($first->id)->toMatch('/^[0-9a-f-]{36}$/') + ->and($first->deadLetteredAt)->toBeInstanceOf(DateTimeImmutable::class) + ->and(array_map(static fn (DeadLetterEntry $e): string => $e->envelope->eventType, $store->find(new DeadLetterQuery(destination: 'orders'))))->toBe(['order.placed', 'order.paid']) + ->and($store->find(new DeadLetterQuery(eventType: 'user.created')))->toHaveCount(1) + ->and($store->find(new DeadLetterQuery(limit: 1)))->toHaveCount(1) + ->and($store->find(new DeadLetterQuery(ids: [(string) $first->id])))->toHaveCount(1) + ->and($store->remove((string) $first->id))->toBeTrue() + ->and($store->remove((string) $first->id))->toBeFalse() + ->and($store->all())->toHaveCount(2); +}); diff --git a/packages/eda/tests/DeadLetter/DeadLetterStoreSelectionTest.php b/packages/eda/tests/DeadLetter/DeadLetterStoreSelectionTest.php new file mode 100644 index 00000000..3fc24b1e --- /dev/null +++ b/packages/eda/tests/DeadLetter/DeadLetterStoreSelectionTest.php @@ -0,0 +1,68 @@ + ['eda' => $store === null ? [] : ['dead-letter' => ['store' => $store]]]])); +} + +function deadLetterResolver(): ConnectionResolverInterface +{ + return new class(new SQLiteConnection(new PDO('sqlite::memory:'), ':memory:')) implements ConnectionResolverInterface + { + public function __construct(private readonly SQLiteConnection $connection) {} + + public function connection($name = null): SQLiteConnection + { + return $this->connection; + } + + public function getDefaultConnection(): string + { + return 'default'; + } + + public function setDefaultConnection($name): void {} + }; +} + +/** A container holding what a booted app holds: the Config, and the connection resolver unless $withDatabase is false. */ +function deadLetterContainer(?string $store, bool $withDatabase = true): Container +{ + $container = new Container; + $container->instance(Config::class, deadLetterConfig($store)); + if ($withDatabase) { + $container->instance(ConnectionResolverInterface::class, deadLetterResolver()); + } + + return $container; +} + +it('selects the dead-letter store by firefly.eda.dead-letter.store', function () { + expect((new EdaAutoConfiguration)->deadLetterStore())->toBeInstanceOf(InMemoryDeadLetterStore::class) + ->and((new EdaAutoConfiguration)->deadLetterStore(deadLetterContainer(null)))->toBeInstanceOf(InMemoryDeadLetterStore::class) + ->and((new EdaAutoConfiguration)->deadLetterStore(deadLetterContainer('memory')))->toBeInstanceOf(InMemoryDeadLetterStore::class) + ->and((new EdaAutoConfiguration)->deadLetterStore(deadLetterContainer('database')))->toBeInstanceOf(DatabaseDeadLetterStore::class) + ->and(fn () => (new EdaAutoConfiguration)->deadLetterStore(deadLetterContainer('database', withDatabase: false)))->toThrow(ConfigurationException::class, 'firefly.eda.dead-letter.store=database') + ->and(fn () => (new EdaAutoConfiguration)->deadLetterStore(deadLetterContainer('redis')))->toThrow(ConfigurationException::class, 'memory|database'); +}); + +it('boots an app without a database on the memory store: the resolver is made only when database is chosen', function () { + $context = bootFireflyApp(['firefly' => ['eda' => []]], [EdaServiceProvider::class, EdaWiringProvider::class]); + + expect($context->get(DeadLetterStore::class))->toBeInstanceOf(InMemoryDeadLetterStore::class); +}); diff --git a/packages/eda/tests/DeadLetterFixtures/FlakyOrderListener.php b/packages/eda/tests/DeadLetterFixtures/FlakyOrderListener.php new file mode 100644 index 00000000..f4c1d1d3 --- /dev/null +++ b/packages/eda/tests/DeadLetterFixtures/FlakyOrderListener.php @@ -0,0 +1,28 @@ +spy->record($envelope->eventType.'#'.($envelope->headers['x-replayed-from'] ?? 'first')); + } +} diff --git a/packages/eda/tests/Integration/DatabaseDeadLetterStoreIntegrationTest.php b/packages/eda/tests/Integration/DatabaseDeadLetterStoreIntegrationTest.php new file mode 100644 index 00000000..eab650f9 --- /dev/null +++ b/packages/eda/tests/Integration/DatabaseDeadLetterStoreIntegrationTest.php @@ -0,0 +1,78 @@ +down() : $migration->up(); +} + +foreach (IntegrationDsn::lanes() as $lane) { + it("keeps, selects and removes eda dead letters on {$lane}", function () use ($lane): void { + config(['database.connections.it_dlq' => IntegrationDsn::connectionConfig($lane, $lane === 'sqlite' ? ['database' => IntegrationDsn::sqlitePath('eda-dlq')] : [])]); + DB::setDefaultConnection('it_dlq'); + edaDeadLetterMigrate('down'); + edaDeadLetterMigrate('up'); + + try { + $connections = App::make(ConnectionResolverInterface::class); + $at = static fn (string $instant): DatabaseDeadLetterStore => new DatabaseDeadLetterStore($connections, 'it_dlq', static fn (): DateTimeImmutable => new DateTimeImmutable($instant, new DateTimeZone('UTC'))); + + $payload = ['customer' => 'Ñandú Café — 東京', 'lines' => [['sku' => 'A', 'qty' => 2]], 'total' => 12.5]; + $at('2026-10-12 10:00:00.200')->store(new EventEnvelope('order.placed', 'orders', $payload, ['x-original-topic' => 'orders', 'x-exception' => 'boom'], 'evt-1'), new RuntimeException('boom')); + $at('2026-10-12 10:00:00.100')->store(new EventEnvelope('order.paid', 'orders', ['id' => 1]), new LogicException('first')); + $at('2026-10-12 10:00:00.300')->store(new EventEnvelope('user.created', 'users'), new RuntimeException('third')); + + $store = $at('2026-10-12 10:00:01'); + $all = $store->all(); + + expect(array_map(static fn (DeadLetterEntry $entry): string => $entry->envelope->eventType, $all))->toBe(['order.paid', 'order.placed', 'user.created']) + // toEqual, not toBe: MySQL's JSON type re-orders object keys, and order is not part of the contract. + ->and($all[1]->envelope->payload)->toEqual($payload) + ->and($all[1]->envelope->headers)->toEqual(['x-original-topic' => 'orders', 'x-exception' => 'boom']) + ->and($all[1]->envelope->eventId)->toBe('evt-1') + ->and($all[1]->deadLetteredAt?->format('Y-m-d H:i:s.v'))->toBe('2026-10-12 10:00:00.200') + ->and($store->find(new DeadLetterQuery(destination: 'orders', eventType: 'order.placed')))->toHaveCount(1) + ->and($store->find(new DeadLetterQuery(limit: 2)))->toHaveCount(2) + ->and($store->remove((string) $all[0]->id))->toBeTrue() + ->and($store->find(new DeadLetterQuery(ids: [(string) $all[0]->id])))->toBe([]) + ->and($store->all())->toHaveCount(2); + + // A tie on the instant: the time-ordered id keeps insertion order under this server's collation. + $tied = $at('2026-10-12 10:00:02.000'); + $types = array_map(static fn (int $i): string => "tie.{$i}", range(1, 20)); + foreach ($types as $type) { + $tied->store(new EventEnvelope($type, 'ties'), new RuntimeException('tie')); + } + expect(array_map(static fn (DeadLetterEntry $entry): string => $entry->envelope->eventType, $store->find(new DeadLetterQuery(destination: 'ties'))))->toBe($types); + } finally { + edaDeadLetterMigrate('down'); + DB::connection('it_dlq')->disconnect(); + } + })->group('integration')->skip(IntegrationDsn::skipReason($lane) !== null, (string) IntegrationDsn::skipReason($lane)); +} diff --git a/packages/eda/tests/Support/DeadLetterCapstoneTestCase.php b/packages/eda/tests/Support/DeadLetterCapstoneTestCase.php new file mode 100644 index 00000000..0a1b9a79 --- /dev/null +++ b/packages/eda/tests/Support/DeadLetterCapstoneTestCase.php @@ -0,0 +1,73 @@ + */ + protected function fireflyProviders(): array + { + return [EdaServiceProvider::class, EdaWiringProvider::class, EdaConsumerServiceProvider::class]; + } + + /** @return array */ + protected function configOverrides(): array + { + return ['firefly.eda.provider' => 'memory', 'firefly.eda.dead-letter.store' => 'database']; + } + + protected function defineFireflyEnvironment(Application $app): void + { + FlakyOrderListener::$failing = true; + $app->instance(EventListenerManifest::class, new EventListenerManifest( + (new EventListenerScanner)->scan(['Firefly\\Eda\\Tests\\DeadLetterFixtures\\' => dirname(__DIR__).'/DeadLetterFixtures']), + )); + $app->singleton(ListenerSpy::class); + } + + protected function setUp(): void + { + parent::setUp(); + + Schema::create(DeadLetterSchema::TABLE, fn (Blueprint $table) => DeadLetterSchema::blueprint($table)); + } + + /** @param array $options */ + public function replay(array $options = []): int + { + /** @var Kernel $kernel */ + $kernel = $this->app()->make(Kernel::class); + + return $kernel->call('firefly:eda:dead-letters:replay', $options); + } + + public function replayOutput(): string + { + /** @var Kernel $kernel */ + $kernel = $this->app()->make(Kernel::class); + + return $kernel->output(); + } +} diff --git a/packages/feature-flags/src/Store/DatabaseFlagStore.php b/packages/feature-flags/src/Store/DatabaseFlagStore.php index 0e2f3ab0..9226303d 100644 --- a/packages/feature-flags/src/Store/DatabaseFlagStore.php +++ b/packages/feature-flags/src/Store/DatabaseFlagStore.php @@ -60,6 +60,9 @@ public function all(): array $flag = self::storedFlag($row); $flags[$flag->key] = $flag; } + // Byte order, like MemoryFlagStore: ORDER BY follows the column collation, which differs per server and locale + // (PostgreSQL under en_US.UTF-8 sorts 'a' before 'A'; MySQL's case-insensitive collations tie them). + ksort($flags, SORT_STRING); return $flags; } diff --git a/packages/feature-flags/tests/Store/Integration/DatabaseFlagStoreAbsentRaceTest.php b/packages/feature-flags/tests/Store/Integration/DatabaseFlagStoreAbsentRaceTest.php index f7486b6f..c6f365f6 100644 --- a/packages/feature-flags/tests/Store/Integration/DatabaseFlagStoreAbsentRaceTest.php +++ b/packages/feature-flags/tests/Store/Integration/DatabaseFlagStoreAbsentRaceTest.php @@ -5,6 +5,7 @@ use Firefly\FeatureFlags\Store\DatabaseFlagStore; use Firefly\FeatureFlags\Store\FeatureFlagSchema; use Firefly\FeatureFlags\Tests\Support\DatabaseStoreIntegration; +use Firefly\Testing\Integration\IntegrationDsn; use Illuminate\Database\Schema\Blueprint; use Symfony\Component\Process\InputStream; use Symfony\Component\Process\Process; @@ -98,7 +99,7 @@ $connection->getSchemaBuilder()->drop('recovery_markers'); FeatureFlagSchema::drop($connection->getSchemaBuilder()); } - })->with($ownership === 'root' ? ['direct'] : ['direct', 'callback'])->group('integration')->skip(getenv($variable) === false, "Set {$variable} to an isolated server."); + })->with($ownership === 'root' ? ['direct'] : ['direct', 'callback'])->group('integration')->skip(IntegrationDsn::missing($variable), "Set {$variable} to an isolated server."); } } } diff --git a/packages/feature-flags/tests/Store/Integration/DatabaseFlagStoreIntegrationTest.php b/packages/feature-flags/tests/Store/Integration/DatabaseFlagStoreIntegrationTest.php index 6294eac6..00ef78f3 100644 --- a/packages/feature-flags/tests/Store/Integration/DatabaseFlagStoreIntegrationTest.php +++ b/packages/feature-flags/tests/Store/Integration/DatabaseFlagStoreIntegrationTest.php @@ -8,6 +8,7 @@ use Firefly\FeatureFlags\Store\FlagStoreConflict; use Firefly\FeatureFlags\Tests\Support\DatabaseStoreIntegration; use Firefly\FeatureFlags\Tests\Support\FlagStores; +use Firefly\Testing\Integration\IntegrationDsn; use Illuminate\Database\QueryException; use Symfony\Component\Process\Process; @@ -32,7 +33,7 @@ expect($store->get('A'))->toBeNull()->and($store->get('a')?->version)->toBe(1) ->and($store->history('A')[0]->previous)->toBe($store->get('a')?->definition); FeatureFlagSchema::drop($connection->getSchemaBuilder()); - })->group('integration')->skip(getenv($variable) === false, "Set {$variable} to an isolated server."); + })->group('integration')->skip(IntegrationDsn::missing($variable), "Set {$variable} to an isolated server."); it("rolls back failed audit writes inside an outer transaction on {$backend}", function () use ($driver, $variable): void { $connection = DatabaseStoreIntegration::connection($driver, $variable); @@ -51,7 +52,7 @@ expect($store->get('new'))->toBeNull()->and($store->get('keep')?->version)->toBe(1) ->and(count($store->history('keep')))->toBe(1); FeatureFlagSchema::drop($connection->getSchemaBuilder()); - })->group('integration')->skip(getenv($variable) === false, "Set {$variable} to an isolated server."); + })->group('integration')->skip(IntegrationDsn::missing($variable), "Set {$variable} to an isolated server."); it("serializes competing process updates and emits only surviving callbacks on {$backend}", function () use ($driver, $variable): void { $connection = DatabaseStoreIntegration::connection($driver, $variable); FeatureFlagSchema::drop($connection->getSchemaBuilder()); @@ -91,7 +92,7 @@ $process->stop(); FeatureFlagSchema::drop($connection->getSchemaBuilder()); } - })->group('integration')->skip(getenv($variable) === false, "Set {$variable} to an isolated server."); + })->group('integration')->skip(IntegrationDsn::missing($variable), "Set {$variable} to an isolated server."); it("reads PyFly microsecond UTC rows independently of the SQL session zone on {$backend}", function () use ($driver, $variable): void { $connection = DatabaseStoreIntegration::connection($driver, $variable); @@ -106,6 +107,6 @@ expect($store->get('pyfly')?->updatedAt->format('Y-m-d H:i:s.uP'))->toBe('2026-10-01 12:00:00.123456+00:00') ->and($store->history('pyfly')[0]->changedAt->format('Y-m-d H:i:s.uP'))->toBe('2026-10-01 12:00:00.123456+00:00'); FeatureFlagSchema::drop($connection->getSchemaBuilder()); - })->group('integration')->skip(getenv($variable) === false, "Set {$variable} to an isolated server."); + })->group('integration')->skip(IntegrationDsn::missing($variable), "Set {$variable} to an isolated server."); } diff --git a/packages/feature-flags/tests/Support/DatabaseStoreIntegration.php b/packages/feature-flags/tests/Support/DatabaseStoreIntegration.php index 7e0131dc..97230eab 100644 --- a/packages/feature-flags/tests/Support/DatabaseStoreIntegration.php +++ b/packages/feature-flags/tests/Support/DatabaseStoreIntegration.php @@ -4,7 +4,7 @@ namespace Firefly\FeatureFlags\Tests\Support; -use Illuminate\Database\Capsule\Manager; +use Firefly\Testing\Integration\IntegrationDsn; use Illuminate\Database\Connection; use RuntimeException; @@ -12,22 +12,10 @@ final class DatabaseStoreIntegration { public static function connection(string $driver, string $variable): Connection { - $dsn = getenv($variable); - if ($dsn === false || $dsn === '') { + if (IntegrationDsn::missing($variable)) { throw new RuntimeException('An isolated integration DSN is required.'); } - $parts = []; - foreach (explode(';', $dsn) as $pair) { - [$key, $value] = array_pad(explode('=', $pair, 2), 2, ''); - $parts[$key] = $value; - } - $capsule = new Manager; - $capsule->addConnection([ - 'driver' => $driver, 'host' => $parts['host'], 'port' => (int) $parts['port'], - 'database' => $parts['dbname'], 'username' => $parts['user'], 'password' => $parts['password'], - 'charset' => $driver === 'pgsql' ? 'utf8' : 'utf8mb4', 'prefix' => '', 'schema' => 'public', - ]); - return $capsule->getConnection(); + return IntegrationDsn::connectFor($driver, $variable); } } diff --git a/packages/firefly/composer.json b/packages/firefly/composer.json index faee7fec..5eb1f6c3 100644 --- a/packages/firefly/composer.json +++ b/packages/firefly/composer.json @@ -33,6 +33,7 @@ "firefly/data": "*@dev", "firefly/domain": "*@dev", "firefly/eda": "*@dev", + "firefly/eda-database": "*@dev", "firefly/feature-flags": "*@dev", "firefly/kernel": "*@dev", "firefly/messaging": "*@dev", @@ -40,6 +41,7 @@ "firefly/openapi": "*@dev", "firefly/resilience": "*@dev", "firefly/scheduling": "*@dev", + "firefly/scheduling-database": "*@dev", "firefly/scheduling-postgres": "*@dev", "firefly/security": "*@dev", "firefly/security-oauth2-client": "*@dev", diff --git a/packages/installer/README.md b/packages/installer/README.md index f61700df..73926c7a 100644 --- a/packages/installer/README.md +++ b/packages/installer/README.md @@ -31,7 +31,8 @@ A capability records a requirement in the generated `composer.json`, satisfied b `replace` metadata. All component code is included; configuration selects optional features. `--with=testing` also adds the external test harness to the application's development dependencies. -Adapters (`eda-kafka`, `eda-rabbitmq`, `eda-postgres`, `scheduling-postgres`) pull their port in with them +Adapters (`eda-kafka`, `eda-rabbitmq`, `eda-database`, `eda-postgres`, `scheduling-database`, +`scheduling-postgres`) pull their port in with them and are deliberately **excluded from `--full`**: which broker or engine an application talks to is not something an archetype can guess. Their code is already bundled, but configuring PostgreSQL or Kafka still requires the corresponding PHP extension. `security-oauth2-client` pulls `security` in the same way — a login with no diff --git a/packages/installer/src/CapabilityCatalog.php b/packages/installer/src/CapabilityCatalog.php index 395f3719..bb0c0e9b 100644 --- a/packages/installer/src/CapabilityCatalog.php +++ b/packages/installer/src/CapabilityCatalog.php @@ -47,7 +47,9 @@ public static function all(): array new Capability('eda-kafka', 'firefly/eda-kafka', 'Kafka publisher/consumer over ext-rdkafka', requires: ['eda'], adapter: true), new Capability('eda-rabbitmq', 'firefly/eda-rabbitmq', 'RabbitMQ publisher/consumer over php-amqplib', requires: ['eda'], adapter: true), - new Capability('eda-postgres', 'firefly/eda-postgres', 'Postgres same-transaction outbox publisher', requires: ['eda'], adapter: true), + new Capability('eda-database', 'firefly/eda-database', 'Portable same-transaction outbox on any database connection, safe with concurrent consumers', requires: ['eda'], adapter: true), + new Capability('eda-postgres', 'firefly/eda-postgres', 'Postgres same-transaction outbox: the database outbox plus pg_notify/LISTEN', requires: ['eda-database'], adapter: true), + new Capability('scheduling-database', 'firefly/scheduling-database', 'Database lease-table and MySQL named-lock DistributedLock backends', requires: ['scheduling'], adapter: true), new Capability('scheduling-postgres', 'firefly/scheduling-postgres', 'Postgres advisory-lock DistributedLock backend', requires: ['scheduling'], adapter: true), new Capability('testing', 'firefly/testing', 'The first-party test kit: boot harness, sqlite fixtures, assertions', dev: true), diff --git a/packages/messaging/README.md b/packages/messaging/README.md index 0c15018f..2c94efc9 100644 --- a/packages/messaging/README.md +++ b/packages/messaging/README.md @@ -3,7 +3,7 @@ LaraFly's lower-level raw-bytes messaging: a `MessageBrokerPort` (topic + bytes value + key + consumer group) with an in-memory default adapter and a Laravel-queue async adapter, a broker-agnostic `#[MessageListener]` attribute (carrying `retries`/`retryDelay`/`deadLetterTopic`) compiled into a `MessageListenerManifest` and wired -onto the broker at boot, and a bytes-aware retry / dead-letter helper. Real brokers (Kafka/RabbitMQ) ship as their -own `firefly/messaging-` packages at SP-4. +onto the broker at boot, and a bytes-aware retry / dead-letter helper. Durable dead letters +(`firefly.messaging.dead-letter.store=database`) and `firefly:messaging:dead-letters:replay` ship here. Apache-2.0 © Firefly Software Solutions Inc. diff --git a/packages/messaging/cache/firefly-messaging-components.php b/packages/messaging/cache/firefly-messaging-components.php index e3ca7799..632f76ee 100644 --- a/packages/messaging/cache/firefly-messaging-components.php +++ b/packages/messaging/cache/firefly-messaging-components.php @@ -38,6 +38,7 @@ 'order' => 0, 'lazy' => false, 'dependencies' => [ + 0 => 'Illuminate\\Container\\Container', ], ], ], diff --git a/packages/messaging/composer.json b/packages/messaging/composer.json index 01f47eb6..11aa6cac 100644 --- a/packages/messaging/composer.json +++ b/packages/messaging/composer.json @@ -27,8 +27,10 @@ "firefly/context": "*@dev", "firefly/kernel": "*@dev", "illuminate/bus": "^13.0", + "illuminate/console": "^13.0", "illuminate/container": "^13.0", "illuminate/contracts": "^13.0", + "illuminate/database": "^13.0", "illuminate/queue": "^13.0", "illuminate/support": "^13.0" }, @@ -36,7 +38,8 @@ "laravel": { "providers": [ "Firefly\\Messaging\\MessagingServiceProvider", - "Firefly\\Messaging\\MessagingWiringProvider" + "Firefly\\Messaging\\MessagingWiringProvider", + "Firefly\\Messaging\\MessagingConsoleServiceProvider" ] }, "branch-alias": { diff --git a/packages/messaging/database/migrations/2026_10_12_000300_create_firefly_messaging_dead_letters_table.php b/packages/messaging/database/migrations/2026_10_12_000300_create_firefly_messaging_dead_letters_table.php new file mode 100644 index 00000000..4eb1beca --- /dev/null +++ b/packages/messaging/database/migrations/2026_10_12_000300_create_firefly_messaging_dead_letters_table.php @@ -0,0 +1,21 @@ + DeadLetterSchema::blueprint($table)); + } + + public function down(): void + { + Schema::dropIfExists(DeadLetterSchema::TABLE); + } +}; diff --git a/packages/messaging/src/Console/ReplayDeadLettersCommand.php b/packages/messaging/src/Console/ReplayDeadLettersCommand.php new file mode 100644 index 00000000..cc927c1f --- /dev/null +++ b/packages/messaging/src/Console/ReplayDeadLettersCommand.php @@ -0,0 +1,90 @@ +` is added, so + * a listener can tell a replay from a first delivery. Each entry that republished is removed unless --keep; one that + * fails is reported and kept, and the command exits FAILURE. Replay is at-least-once, like firefly:eda:dead-letters: + * replay: a crash between the publish and the removal replays that entry again next time. + */ +final class ReplayDeadLettersCommand extends Command +{ + /** @var string */ + protected $signature = 'firefly:messaging:dead-letters:replay {--id=* : replay only these entries} {--topic= : only entries of this topic (the dead-letter topic or the original one)} {--limit=100 : at most this many entries} {--dry-run : list what would be replayed and change nothing} {--keep : keep each entry after it is republished}'; + + /** @var string */ + protected $description = 'Republish dead-lettered messages through the bound MessageBrokerPort, removing each one that republished.'; + + public function handle(DeadLetterStore $store, MessageBrokerPort $broker): int + { + if (! $store instanceof ReplayableDeadLetterStore) { + $this->error(sprintf('The bound messaging DeadLetterStore (%s) cannot select or remove entries. Bind a ReplayableDeadLetterStore, or drop the binding and set firefly.messaging.dead-letter.store=database.', get_debug_type($store))); + + return self::FAILURE; + } + + $entries = $store->find($this->query()); + + $replayed = 0; + $failed = 0; + foreach ($entries as $entry) { + $message = $entry->message; + $target = $message->headers['x-original-topic'] ?? $message->topic; + $headers = array_diff_key($message->headers, ['x-original-topic' => true, 'x-exception' => true]); + $headers['x-replayed-from'] = (string) $entry->id; + + if ($this->option('dry-run') === true) { + $this->line(sprintf('would replay %s %s -> %s', $entry->id, $message->topic, $target)); + + continue; + } + + try { + $broker->publish($target, $message->value, $message->key, $headers); + if ($this->option('keep') !== true && $entry->id !== null) { + $store->remove($entry->id); + } + $replayed++; + $this->line(sprintf('replayed %s %s -> %s', $entry->id, $message->topic, $target)); + } catch (Throwable $e) { + $failed++; + $this->error(sprintf('could not replay %s %s: %s', $entry->id, $message->topic, $e->getMessage())); + } + } + + $this->info(sprintf('firefly:messaging:dead-letters:replay — %d selected, %d replayed, %d failed.', count($entries), $replayed, $failed)); + + return $failed === 0 ? self::SUCCESS : self::FAILURE; + } + + private function query(): DeadLetterQuery + { + $ids = []; + foreach ((array) $this->option('id') as $id) { + if (is_string($id) && $id !== '') { + $ids[] = $id; + } + } + + $topic = $this->option('topic'); + $limit = $this->option('limit'); + + return new DeadLetterQuery( + $ids, + is_string($topic) && $topic !== '' ? $topic : null, + is_numeric($limit) ? max(1, (int) $limit) : 100, + ); + } +} diff --git a/packages/messaging/src/DeadLetter/DatabaseDeadLetterStore.php b/packages/messaging/src/DeadLetter/DatabaseDeadLetterStore.php new file mode 100644 index 00000000..7ccd0fcd --- /dev/null +++ b/packages/messaging/src/DeadLetter/DatabaseDeadLetterStore.php @@ -0,0 +1,133 @@ +clock === null ? new DateTimeImmutable : ($this->clock)(); + + $this->db()->table(DeadLetterSchema::TABLE)->insert([ + 'id' => (string) Str::uuid7(), + 'topic' => $message->topic, + 'original_topic' => $message->headers['x-original-topic'] ?? null, + 'message_key' => $message->key === null ? null : base64_encode($message->key), + 'value' => base64_encode($message->value), + 'headers' => json_encode($message->headers === [] ? new stdClass : $message->headers, JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE), + 'exception_class' => $cause::class, + 'exception_message' => mb_scrub($cause->getMessage(), 'UTF-8'), + 'dead_lettered_at' => $now->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s.v'), + ]); + } + + /** + * @return list + */ + public function all(): array + { + return $this->find(new DeadLetterQuery(limit: null)); + } + + public function find(DeadLetterQuery $query): array + { + $select = $this->db()->table(DeadLetterSchema::TABLE)->orderBy('dead_lettered_at')->orderBy('id'); + + if ($query->ids !== []) { + $select->whereIn('id', $query->ids); + } + if ($query->topic !== null) { + $topic = $query->topic; + $select->where(static function (Builder $where) use ($topic): void { + $where->where('topic', $topic)->orWhere('original_topic', $topic); + }); + } + if ($query->limit !== null) { + $select->limit(max(1, $query->limit)); + } + + $entries = []; + foreach ($select->get() as $row) { + $entries[] = self::entry($row); + } + + return $entries; + } + + public function remove(string $id): bool + { + return $this->db()->table(DeadLetterSchema::TABLE)->where('id', $id)->delete() === 1; + } + + private function db(): ConnectionInterface + { + return $this->connections->connection($this->connection); + } + + private static function entry(object $row): DeadLetterEntry + { + $columns = get_object_vars($row); + $value = base64_decode(self::text($columns['value'] ?? null), true); + $key = $columns['message_key'] ?? null; + $key = $key === null ? false : base64_decode(self::text($key), true); + $at = self::text($columns['dead_lettered_at'] ?? null); + + $headers = []; + $json = self::text($columns['headers'] ?? null); + $map = $json === '' ? [] : json_decode($json, true, 512, JSON_THROW_ON_ERROR); + foreach (is_array($map) ? $map : [] as $name => $header) { + if (is_scalar($header)) { + $headers[(string) $name] = (string) $header; + } + } + + return new DeadLetterEntry( + new Message(self::text($columns['topic'] ?? null), $value === false ? '' : $value, $key === false ? null : $key, $headers), + self::text($columns['exception_class'] ?? null), + self::text($columns['exception_message'] ?? null), + self::text($columns['id'] ?? null), + $at === '' ? null : new DateTimeImmutable($at, new DateTimeZone('UTC')), + ); + } + + private static function text(mixed $value): string + { + return is_scalar($value) ? (string) $value : ''; + } +} diff --git a/packages/messaging/src/DeadLetter/DeadLetterEntry.php b/packages/messaging/src/DeadLetter/DeadLetterEntry.php index 26c4e95e..b9b17822 100644 --- a/packages/messaging/src/DeadLetter/DeadLetterEntry.php +++ b/packages/messaging/src/DeadLetter/DeadLetterEntry.php @@ -4,11 +4,14 @@ namespace Firefly\Messaging\DeadLetter; +use DateTimeImmutable; use Firefly\Messaging\Message; /** * One dead-lettered message plus the exception class + message that caused it. The message is already re-keyed to - * the dead-letter topic and carries the x-original-topic / x-exception headers added by RetryingMessageHandler. + * the dead-letter topic and carries the x-original-topic / x-exception headers added by RetryingMessageHandler. A + * replayable store fills `id` (what firefly:messaging:dead-letters:replay selects and removes by) and + * `deadLetteredAt`. */ final readonly class DeadLetterEntry { @@ -16,5 +19,7 @@ public function __construct( public Message $message, public string $exceptionClass, public string $exceptionMessage, + public ?string $id = null, + public ?DateTimeImmutable $deadLetteredAt = null, ) {} } diff --git a/packages/messaging/src/DeadLetter/DeadLetterQuery.php b/packages/messaging/src/DeadLetter/DeadLetterQuery.php new file mode 100644 index 00000000..a108d15b --- /dev/null +++ b/packages/messaging/src/DeadLetter/DeadLetterQuery.php @@ -0,0 +1,30 @@ + $ids empty = any + * @param int|null $limit null = all + */ + public function __construct( + public array $ids = [], + public ?string $topic = null, + public ?int $limit = 100, + ) {} + + public function matches(DeadLetterEntry $entry): bool + { + return ($this->ids === [] || in_array($entry->id, $this->ids, true)) + && ($this->topic === null + || $entry->message->topic === $this->topic + || ($entry->message->headers['x-original-topic'] ?? null) === $this->topic); + } +} diff --git a/packages/messaging/src/DeadLetter/DeadLetterSchema.php b/packages/messaging/src/DeadLetter/DeadLetterSchema.php new file mode 100644 index 00000000..0cf08f4b --- /dev/null +++ b/packages/messaging/src/DeadLetter/DeadLetterSchema.php @@ -0,0 +1,38 @@ +string('id', 36)->primary(); + $table->string('topic'); + $table->string('original_topic')->nullable(); + $table->text('message_key')->nullable(); + $table->longText('value'); + $table->json('headers'); + $table->string('exception_class'); + $table->text('exception_message'); + $table->dateTime('dead_lettered_at', 3); + + $table->index(['topic'], 'firefly_messaging_dead_letters_topic_idx'); + $table->index(['dead_lettered_at'], 'firefly_messaging_dead_letters_at_idx'); + } +} diff --git a/packages/messaging/src/DeadLetter/DeadLetterStore.php b/packages/messaging/src/DeadLetter/DeadLetterStore.php index 89316717..ad1f5dce 100644 --- a/packages/messaging/src/DeadLetter/DeadLetterStore.php +++ b/packages/messaging/src/DeadLetter/DeadLetterStore.php @@ -10,7 +10,9 @@ /** * The messaging dead-letter sink (pyfly messaging/error_handling.py parity, bytes flavour). A consumer that * exhausts its retries has its message stored here re-keyed to its deadLetterTopic. Distinct from eda's - * DeadLetterStore (envelope vs raw-bytes) — no shared class, no cross-package edge. + * DeadLetterStore (envelope vs raw-bytes) — no shared class, no cross-package edge. InMemoryDeadLetterStore is the + * default; DatabaseDeadLetterStore (firefly.messaging.dead-letter.store=database) keeps them durably. Both implement + * ReplayableDeadLetterStore, which firefly:messaging:dead-letters:replay needs. */ interface DeadLetterStore { diff --git a/packages/messaging/src/DeadLetter/InMemoryDeadLetterStore.php b/packages/messaging/src/DeadLetter/InMemoryDeadLetterStore.php index b5694756..f2bb113f 100644 --- a/packages/messaging/src/DeadLetter/InMemoryDeadLetterStore.php +++ b/packages/messaging/src/DeadLetter/InMemoryDeadLetterStore.php @@ -4,20 +4,23 @@ namespace Firefly\Messaging\DeadLetter; +use DateTimeImmutable; use Firefly\Messaging\Message; +use Illuminate\Support\Str; use Throwable; /** - * The default messaging DeadLetterStore: an in-process append-only list. Zero external services. + * The default messaging DeadLetterStore: an in-process append-only list. Zero external services — and lost when the + * process ends; firefly.messaging.dead-letter.store=database keeps dead letters durably. */ -final class InMemoryDeadLetterStore implements DeadLetterStore +final class InMemoryDeadLetterStore implements ReplayableDeadLetterStore { /** @var list */ private array $entries = []; public function store(Message $message, Throwable $cause): void { - $this->entries[] = new DeadLetterEntry($message, $cause::class, $cause->getMessage()); + $this->entries[] = new DeadLetterEntry($message, $cause::class, $cause->getMessage(), (string) Str::uuid(), new DateTimeImmutable); } /** @@ -27,4 +30,24 @@ public function all(): array { return $this->entries; } + + public function find(DeadLetterQuery $query): array + { + $found = array_values(array_filter($this->entries, $query->matches(...))); + + return $query->limit === null ? $found : array_slice($found, 0, max(1, $query->limit)); + } + + public function remove(string $id): bool + { + foreach ($this->entries as $index => $entry) { + if ($entry->id === $id) { + array_splice($this->entries, $index, 1); + + return true; + } + } + + return false; + } } diff --git a/packages/messaging/src/DeadLetter/ReplayableDeadLetterStore.php b/packages/messaging/src/DeadLetter/ReplayableDeadLetterStore.php new file mode 100644 index 00000000..2ed2395f --- /dev/null +++ b/packages/messaging/src/DeadLetter/ReplayableDeadLetterStore.php @@ -0,0 +1,20 @@ + oldest first + */ + public function find(DeadLetterQuery $query): array; + + public function remove(string $id): bool; +} diff --git a/packages/messaging/src/MessagingAutoConfiguration.php b/packages/messaging/src/MessagingAutoConfiguration.php index 9436e2a6..5bb62acb 100644 --- a/packages/messaging/src/MessagingAutoConfiguration.php +++ b/packages/messaging/src/MessagingAutoConfiguration.php @@ -9,15 +9,20 @@ use Firefly\Container\Attributes\Configuration; use Firefly\Container\Attributes\Order; use Firefly\Context\Condition\Attributes\ConditionalOnMissingBean; +use Firefly\Kernel\Exception\Framework\ConfigurationException; use Firefly\Messaging\Broker\InMemoryMessageBroker; use Firefly\Messaging\Broker\QueueMessageBroker; +use Firefly\Messaging\DeadLetter\DatabaseDeadLetterStore; use Firefly\Messaging\DeadLetter\DeadLetterStore; use Firefly\Messaging\DeadLetter\InMemoryDeadLetterStore; use Illuminate\Container\Container; +use Illuminate\Contracts\Container\BindingResolutionException; +use Illuminate\Database\ConnectionResolverInterface; /** * Always-on messaging transport wiring. Selects the MessageBrokerPort adapter by firefly.messaging.provider - * (`memory` default | `queue`) and the DeadLetterStore default. #[Order(1000)] places it after user definitions; + * (`memory` default | `queue`) and the DeadLetterStore by firefly.messaging.dead-letter.store (`memory` default | + * `database`). #[Order(1000)] places it after user definitions; * each #[ConditionalOnMissingBean] lets an app bind its own and win. Both adapters are singletons (the #[Bean] * default), so the wiring pass and the queue worker share ONE subscriber set. Mirrors EdaAutoConfiguration. */ @@ -39,10 +44,44 @@ public function messageBroker(Config $config, Container $container): MessageBrok return new InMemoryMessageBroker; } + /** + * firefly.messaging.dead-letter.store: `memory` (the default — in-process, lost on exit) or `database` (durable, + * replayable, on firefly.messaging.dead-letter.connection). It takes the container, not Config and the connection + * resolver, for the reason EdaAutoConfiguration::deadLetterStore() does: Config is autowired, never bound, so + * Container::call() hands a nullable `?Config $config = null` its null default; and Laravel aliases + * ConnectionResolverInterface to `db` even in an app that never registered DatabaseServiceProvider, so injecting + * it would break every boot without a database. The resolver is made only when `database` is chosen. A direct + * call with no container (the unit tests) yields the default. + */ #[Bean] #[ConditionalOnMissingBean(DeadLetterStore::class)] - public function deadLetterStore(): DeadLetterStore + public function deadLetterStore(?Container $container = null): DeadLetterStore { - return new InMemoryDeadLetterStore; + if ($container === null) { + return new InMemoryDeadLetterStore; + } + + /** @var Config $config */ + $config = $container->make(Config::class); + $store = $config->string('firefly.messaging.dead-letter.store', 'memory'); + + if ($store === 'memory') { + return new InMemoryDeadLetterStore; + } + + if ($store !== 'database') { + throw new ConfigurationException("firefly.messaging.dead-letter.store='{$store}' is not one of [memory|database]."); + } + + try { + $connections = $container->make(ConnectionResolverInterface::class); + } catch (BindingResolutionException $e) { + throw new ConfigurationException('firefly.messaging.dead-letter.store=database needs a database connection resolver; register Laravel\'s DatabaseServiceProvider (every application has it) or bind your own DeadLetterStore.', previous: $e); + } + + return new DatabaseDeadLetterStore( + $connections, + $config->has('firefly.messaging.dead-letter.connection') ? $config->string('firefly.messaging.dead-letter.connection') : null, + ); } } diff --git a/packages/messaging/src/MessagingConsoleServiceProvider.php b/packages/messaging/src/MessagingConsoleServiceProvider.php new file mode 100644 index 00000000..1c65852c --- /dev/null +++ b/packages/messaging/src/MessagingConsoleServiceProvider.php @@ -0,0 +1,31 @@ +app->make('config')->get('firefly.messaging.dead-letter.store') === 'database') { + $this->loadMigrationsFrom(self::MIGRATIONS); + } + + if ($this->app->runningInConsole()) { + $this->commands([ReplayDeadLettersCommand::class]); + } + } +} diff --git a/packages/messaging/tests/Console/ReplayDeadLettersCommandTest.php b/packages/messaging/tests/Console/ReplayDeadLettersCommandTest.php new file mode 100644 index 00000000..7d475f77 --- /dev/null +++ b/packages/messaging/tests/Console/ReplayDeadLettersCommandTest.php @@ -0,0 +1,114 @@ +app(); + $app->make(MessageBrokerPort::class)->publish('ledger', "entry-\x00-1", 'k'); + $app->make(MessageBrokerPort::class)->publish('ledger', "entry-\xFF-2", 'k'); + + expect($app->make(DeadLetterStore::class))->toBeInstanceOf(DatabaseDeadLetterStore::class) + ->and(DB::table(DeadLetterSchema::TABLE)->where('topic', 'ledger.DLT')->where('original_topic', 'ledger')->count())->toBe(2); + + // Dry run: lists, changes nothing. + expect($this->replay(['--dry-run' => true]))->toBe(0) + ->and($this->replayOutput())->toContain('would replay') + ->and(DB::table(DeadLetterSchema::TABLE)->count())->toBe(2); + + // The outage is over: replay one, then the rest. + FlakyLedgerListener::$failing = false; + expect($this->replay(['--topic' => 'ledger', '--limit' => 1]))->toBe(0) + ->and(DB::table(DeadLetterSchema::TABLE)->count())->toBe(1) + ->and($this->replay(['--topic' => 'ledger.DLT']))->toBe(0) + ->and(DB::table(DeadLetterSchema::TABLE)->count())->toBe(0); + + /** @var ListenerSpy $spy */ + $spy = $app->make(ListenerSpy::class); + expect($spy->seen)->toHaveCount(2) + ->and($spy->seen[0])->toStartWith("entry-\x00-1#k#") + ->and($spy->seen[0])->not->toEndWith('#first') + ->and($spy->seen[1])->toStartWith("entry-\xFF-2#k#"); +}); + +it('drops the retry headers and marks the replay with x-replayed-from', function () { + /** @var MessagingDeadLetterCapstoneTestCase $this */ + $app = $this->app(); + $app->make(MessageBrokerPort::class)->publish('ledger', 'v', null, ['trace' => 't-1']); + $id = DB::table(DeadLetterSchema::TABLE)->value('id'); + + /** @var list $seen */ + $seen = []; + $app->make(MessageBrokerPort::class)->subscribe('ledger', static function (Message $message) use (&$seen): void { + $seen[] = $message; + }); + FlakyLedgerListener::$failing = false; + + expect($this->replay(['--id' => [$id]]))->toBe(0) + ->and($seen)->toHaveCount(1) + ->and($seen[0]->headers)->toBe(['trace' => 't-1', 'x-replayed-from' => $id]); +}); + +it('dead-letters a replay that fails again, and --keep leaves the original in place', function () { + /** @var MessagingDeadLetterCapstoneTestCase $this */ + $this->app()->make(MessageBrokerPort::class)->publish('ledger', 'still-failing'); + + expect($this->replay(['--keep' => true]))->toBe(0) + ->and(DB::table(DeadLetterSchema::TABLE)->count())->toBe(2); +}); + +it('keeps an entry whose republish throws, and exits 1', function () { + /** @var MessagingDeadLetterCapstoneTestCase $this */ + $this->app()->make(MessageBrokerPort::class)->publish('ledger', 'v'); + $this->app()->instance(MessageBrokerPort::class, new class implements MessageBrokerPort + { + public function publish(string $topic, string $value, ?string $key = null, array $headers = []): void + { + throw new RuntimeException('broker down'); + } + + public function subscribe(string $topic, callable $handler, ?string $group = null): void {} + + public function start(): void {} + + public function stop(): void {} + }); + + expect($this->replay())->toBe(1) + ->and($this->replayOutput())->toContain('could not replay') + ->and(DB::table(DeadLetterSchema::TABLE)->count())->toBe(1); +}); + +it('refuses a bound DeadLetterStore that cannot select or remove entries, and exits 1', function () { + /** @var MessagingDeadLetterCapstoneTestCase $this */ + $this->app()->instance(DeadLetterStore::class, new class implements DeadLetterStore + { + public function store(Message $message, Throwable $cause): void {} + + public function all(): array + { + return []; + } + }); + + expect($this->replay())->toBe(1) + ->and($this->replayOutput())->toContain('cannot select or remove entries'); +}); + +it('ships its migration when the store is database', function () { + /** @var MessagingDeadLetterCapstoneTestCase $this */ + expect($this->app()->make('migrator')->paths())->toContain(MessagingConsoleServiceProvider::MIGRATIONS); +}); diff --git a/packages/messaging/tests/DeadLetter/DatabaseDeadLetterStoreTest.php b/packages/messaging/tests/DeadLetter/DatabaseDeadLetterStoreTest.php new file mode 100644 index 00000000..ad1251a0 --- /dev/null +++ b/packages/messaging/tests/DeadLetter/DatabaseDeadLetterStoreTest.php @@ -0,0 +1,122 @@ +up(); +}); + +function messagingDeadLetterStore(string $now = '2026-10-12 10:00:00.500'): DatabaseDeadLetterStore +{ + /** @var ConnectionResolverInterface $connections */ + $connections = App::make(ConnectionResolverInterface::class); + + return new DatabaseDeadLetterStore($connections, null, static fn (): DateTimeImmutable => new DateTimeImmutable($now, new DateTimeZone('UTC'))); +} + +it('keeps raw bytes byte for byte — NUL, high bytes and all — with key, headers and cause', function () { + $bytes = "\x00\x01\xFF\xFEpayload\n\x80"; + messagingDeadLetterStore()->store(new Message('ledger.DLT', $bytes, 'k-1', ['x-original-topic' => 'ledger', 'x-exception' => 'boom']), new LogicException('boom')); + + $entry = messagingDeadLetterStore()->all()[0]; + + expect($entry->id)->toMatch('/^[0-9a-f-]{36}$/') + ->and($entry->message->value)->toBe($bytes) + ->and($entry->message->topic)->toBe('ledger.DLT') + ->and($entry->message->key)->toBe('k-1') + ->and($entry->message->headers)->toBe(['x-original-topic' => 'ledger', 'x-exception' => 'boom']) + ->and($entry->exceptionClass)->toBe(LogicException::class) + ->and($entry->exceptionMessage)->toBe('boom') + ->and($entry->deadLetteredAt?->format('Y-m-d H:i:s.v'))->toBe('2026-10-12 10:00:00.500') + ->and(DB::table(DeadLetterSchema::TABLE)->value('original_topic'))->toBe('ledger'); + + $stored = DB::table(DeadLetterSchema::TABLE)->value('value'); + expect(is_string($stored) ? base64_decode($stored, true) : null)->toBe($bytes); +}); + +it('keeps a message with no key and no headers', function () { + messagingDeadLetterStore()->store(new Message('plain.DLT', ''), new RuntimeException('empty')); + + $entry = messagingDeadLetterStore()->all()[0]; + + expect($entry->message->value)->toBe('') + ->and($entry->message->key)->toBeNull() + ->and($entry->message->headers)->toBe([]) + ->and(DB::table(DeadLetterSchema::TABLE)->value('original_topic'))->toBeNull(); +}); + +it('selects by id and by either topic, oldest first, and removes one entry', function () { + messagingDeadLetterStore('2026-10-12 10:00:02')->store(new Message('ledger.DLT', 'second', null, ['x-original-topic' => 'ledger']), new RuntimeException('b')); + messagingDeadLetterStore('2026-10-12 10:00:01')->store(new Message('ledger.DLT', 'first', null, ['x-original-topic' => 'ledger']), new RuntimeException('a')); + messagingDeadLetterStore('2026-10-12 10:00:03')->store(new Message('audit.DLT', 'third', null, ['x-original-topic' => 'audit']), new RuntimeException('c')); + $store = messagingDeadLetterStore(); + + $ledger = $store->find(new DeadLetterQuery(topic: 'ledger')); + + expect(array_map(static fn (DeadLetterEntry $e): string => $e->message->value, $ledger))->toBe(['first', 'second']) + ->and($store->find(new DeadLetterQuery(topic: 'ledger.DLT')))->toHaveCount(2) + ->and($store->find(new DeadLetterQuery(topic: 'audit.DLT')))->toHaveCount(1) + ->and($store->find(new DeadLetterQuery(limit: 2)))->toHaveCount(2) + ->and($store->find(new DeadLetterQuery(ids: [(string) $ledger[1]->id]))[0]->message->value)->toBe('second') + ->and($store->remove((string) $ledger[0]->id))->toBeTrue() + ->and($store->remove((string) $ledger[0]->id))->toBeFalse() + ->and(DB::table(DeadLetterSchema::TABLE)->count())->toBe(2); +}); + +it('keeps the message when the cause carries raw bytes: the description is made valid UTF-8, the value is not touched', function () { + $bytes = "\xC3\x28raw"; + messagingDeadLetterStore()->store(new Message('ledger.DLT', $bytes, null, ['x-original-topic' => 'ledger', 'x-exception' => "bad \xC3\x28 input"]), new RuntimeException("bad \xC3\x28 input")); + + $entry = messagingDeadLetterStore()->all()[0]; + + expect($entry->message->value)->toBe($bytes) + ->and(mb_check_encoding($entry->exceptionMessage, 'UTF-8'))->toBeTrue() + ->and(mb_check_encoding($entry->message->headers['x-exception'] ?? '', 'UTF-8'))->toBeTrue() + ->and($entry->message->headers['x-original-topic'] ?? null)->toBe('ledger'); +}); + +it('keeps an opaque key byte for byte — longer than a varchar, and not valid UTF-8 — stored as base64 text', function (string $key) { + messagingDeadLetterStore()->store(new Message('ledger.DLT', 'v', $key, ['x-original-topic' => 'ledger']), new RuntimeException('boom')); + + $entry = messagingDeadLetterStore()->all()[0]; + $stored = DB::table(DeadLetterSchema::TABLE)->value('message_key'); + + expect($entry->message->key)->toBe($key) + ->and(is_string($stored) ? base64_decode($stored, true) : null)->toBe($key) + ->and(is_string($stored) && mb_check_encoding($stored, 'ASCII'))->toBeTrue(); +})->with([ + 'a 300-byte key' => [str_repeat('k', 300)], + 'a non-UTF-8 key' => ["\xFF\x00k"], + 'a raw 16-byte UUID' => [hex2bin('0192f5a1c3d47e8f9a0b1c2d3e4f5061')], + 'an empty key' => [''], +]); + +it('keeps insertion order for dead letters written in the same millisecond, so --limit replays the oldest', function () { + $store = messagingDeadLetterStore('2026-10-12 10:00:00.500'); + $values = array_map(static fn (int $i): string => "same-instant-{$i}", range(1, 40)); + foreach ($values as $value) { + $store->store(new Message('ledger.DLT', $value, null, ['x-original-topic' => 'ledger']), new RuntimeException('tie')); + } + + expect(array_map(static fn (DeadLetterEntry $e): string => $e->message->value, $store->all()))->toBe($values) + ->and($store->find(new DeadLetterQuery(topic: 'ledger', limit: 1))[0]->message->value)->toBe('same-instant-1'); +}); diff --git a/packages/messaging/tests/DeadLetter/DeadLetterQueryTest.php b/packages/messaging/tests/DeadLetter/DeadLetterQueryTest.php new file mode 100644 index 00000000..a6b3e4d0 --- /dev/null +++ b/packages/messaging/tests/DeadLetter/DeadLetterQueryTest.php @@ -0,0 +1,26 @@ +store(new Message('ledger.DLT', 'a', null, ['x-original-topic' => 'ledger']), new RuntimeException('a')); + $store->store(new Message('audit.DLT', 'b', null, ['x-original-topic' => 'audit']), new RuntimeException('b')); + + [$first] = $store->all(); + + expect($first->id)->toMatch('/^[0-9a-f-]{36}$/') + ->and($first->deadLetteredAt)->toBeInstanceOf(DateTimeImmutable::class) + ->and($store->find(new DeadLetterQuery(topic: 'ledger')))->toHaveCount(1) + ->and($store->find(new DeadLetterQuery(topic: 'audit.DLT')))->toHaveCount(1) + ->and($store->find(new DeadLetterQuery(topic: 'nowhere')))->toBe([]) + ->and($store->find(new DeadLetterQuery(limit: 1)))->toHaveCount(1) + ->and($store->find(new DeadLetterQuery(ids: [(string) $first->id])))->toHaveCount(1) + ->and($store->remove((string) $first->id))->toBeTrue() + ->and($store->remove((string) $first->id))->toBeFalse() + ->and($store->all())->toHaveCount(1); +}); diff --git a/packages/messaging/tests/DeadLetter/DeadLetterStoreSelectionTest.php b/packages/messaging/tests/DeadLetter/DeadLetterStoreSelectionTest.php new file mode 100644 index 00000000..dff380fd --- /dev/null +++ b/packages/messaging/tests/DeadLetter/DeadLetterStoreSelectionTest.php @@ -0,0 +1,63 @@ +connection; + } + + public function getDefaultConnection(): string + { + return 'default'; + } + + public function setDefaultConnection($name): void {} + }; +} + +/** A container holding what a booted app holds: the Config, and the connection resolver unless $withDatabase is false. */ +function messagingDeadLetterContainer(?string $store, bool $withDatabase = true): Container +{ + $container = new Container; + $container->instance(Config::class, new Config(new Repository(['firefly' => ['messaging' => $store === null ? [] : ['dead-letter' => ['store' => $store]]]]))); + if ($withDatabase) { + $container->instance(ConnectionResolverInterface::class, messagingDeadLetterResolver()); + } + + return $container; +} + +it('selects the messaging dead-letter store by firefly.messaging.dead-letter.store', function () { + expect((new MessagingAutoConfiguration)->deadLetterStore())->toBeInstanceOf(InMemoryDeadLetterStore::class) + ->and((new MessagingAutoConfiguration)->deadLetterStore(messagingDeadLetterContainer(null)))->toBeInstanceOf(InMemoryDeadLetterStore::class) + ->and((new MessagingAutoConfiguration)->deadLetterStore(messagingDeadLetterContainer('memory')))->toBeInstanceOf(InMemoryDeadLetterStore::class) + ->and((new MessagingAutoConfiguration)->deadLetterStore(messagingDeadLetterContainer('database')))->toBeInstanceOf(DatabaseDeadLetterStore::class) + ->and(fn () => (new MessagingAutoConfiguration)->deadLetterStore(messagingDeadLetterContainer('database', withDatabase: false)))->toThrow(ConfigurationException::class, 'firefly.messaging.dead-letter.store=database') + ->and(fn () => (new MessagingAutoConfiguration)->deadLetterStore(messagingDeadLetterContainer('kafka')))->toThrow(ConfigurationException::class, 'memory|database'); +}); + +it('boots an app without a database on the memory store: the resolver is made only when database is chosen', function () { + $context = bootFireflyApp(['firefly' => ['messaging' => []]], [MessagingServiceProvider::class, MessagingWiringProvider::class]); + + expect($context->get(DeadLetterStore::class))->toBeInstanceOf(InMemoryDeadLetterStore::class); +}); diff --git a/packages/messaging/tests/DeadLetterFixtures/FlakyLedgerListener.php b/packages/messaging/tests/DeadLetterFixtures/FlakyLedgerListener.php new file mode 100644 index 00000000..d520438b --- /dev/null +++ b/packages/messaging/tests/DeadLetterFixtures/FlakyLedgerListener.php @@ -0,0 +1,28 @@ +spy->record($message->value.'#'.($message->key ?? '-').'#'.($message->headers['x-replayed-from'] ?? 'first')); + } +} diff --git a/packages/messaging/tests/Integration/DatabaseDeadLetterStoreIntegrationTest.php b/packages/messaging/tests/Integration/DatabaseDeadLetterStoreIntegrationTest.php new file mode 100644 index 00000000..3d1bf196 --- /dev/null +++ b/packages/messaging/tests/Integration/DatabaseDeadLetterStoreIntegrationTest.php @@ -0,0 +1,75 @@ +down() : $migration->up(); +} + +foreach (IntegrationDsn::lanes() as $lane) { + it("keeps raw bytes and selects messaging dead letters on {$lane}", function () use ($lane): void { + config(['database.connections.it_dlq' => IntegrationDsn::connectionConfig($lane, $lane === 'sqlite' ? ['database' => IntegrationDsn::sqlitePath('messaging-dlq')] : [])]); + DB::setDefaultConnection('it_dlq'); + messagingDeadLetterMigrate('down'); + messagingDeadLetterMigrate('up'); + + try { + $connections = App::make(ConnectionResolverInterface::class); + $store = new DatabaseDeadLetterStore($connections, 'it_dlq'); + $bytes = random_bytes(512)."\x00\xFF\xC3\x28"; + + // An opaque key: longer than any varchar, and not valid UTF-8 (a raw UUID, a hash, an Avro key). + $key = str_repeat('k', 300)."\xFF\x00k"; + + $store->store(new Message('ledger.DLT', $bytes, $key, ['x-original-topic' => 'ledger', 'x-exception' => 'boom']), new RuntimeException('boom')); + $store->store(new Message('audit.DLT', 'plain', null, ['x-original-topic' => 'audit']), new RuntimeException('x')); + + $ledger = $store->find(new DeadLetterQuery(topic: 'ledger')); + + expect($ledger)->toHaveCount(1) + ->and($ledger[0]->message->value)->toBe($bytes) + ->and($ledger[0]->message->key)->toBe($key) + ->and($ledger[0]->message->topic)->toBe('ledger.DLT') + ->and($store->find(new DeadLetterQuery(topic: 'audit.DLT'))[0]->message->key ?? 'missing')->toBe('missing') + ->and($store->remove((string) $ledger[0]->id))->toBeTrue() + ->and($store->all())->toHaveCount(1); + + // A tie on the instant: the time-ordered id keeps insertion order under this server's collation. + $tied = new DatabaseDeadLetterStore($connections, 'it_dlq', static fn (): DateTimeImmutable => new DateTimeImmutable('2026-10-12 10:00:00.000', new DateTimeZone('UTC'))); + $values = array_map(static fn (int $i): string => "tie-{$i}", range(1, 20)); + foreach ($values as $value) { + $tied->store(new Message('ties.DLT', $value, null, ['x-original-topic' => 'ties']), new RuntimeException('tie')); + } + expect(array_map(static fn (DeadLetterEntry $entry): string => $entry->message->value, $store->find(new DeadLetterQuery(topic: 'ties'))))->toBe($values); + } finally { + messagingDeadLetterMigrate('down'); + DB::connection('it_dlq')->disconnect(); + } + })->group('integration')->skip(IntegrationDsn::skipReason($lane) !== null, (string) IntegrationDsn::skipReason($lane)); +} diff --git a/packages/messaging/tests/Support/MessagingDeadLetterCapstoneTestCase.php b/packages/messaging/tests/Support/MessagingDeadLetterCapstoneTestCase.php new file mode 100644 index 00000000..29a5d46d --- /dev/null +++ b/packages/messaging/tests/Support/MessagingDeadLetterCapstoneTestCase.php @@ -0,0 +1,73 @@ + */ + protected function fireflyProviders(): array + { + return [MessagingServiceProvider::class, MessagingWiringProvider::class, MessagingConsoleServiceProvider::class]; + } + + /** @return array */ + protected function configOverrides(): array + { + return ['firefly.messaging.provider' => 'memory', 'firefly.messaging.dead-letter.store' => 'database']; + } + + protected function defineFireflyEnvironment(Application $app): void + { + FlakyLedgerListener::$failing = true; + $app->instance(MessageListenerManifest::class, new MessageListenerManifest( + (new MessageListenerScanner)->scan(['Firefly\\Messaging\\Tests\\DeadLetterFixtures\\' => dirname(__DIR__).'/DeadLetterFixtures']), + )); + $app->singleton(ListenerSpy::class); + } + + protected function setUp(): void + { + parent::setUp(); + + Schema::create(DeadLetterSchema::TABLE, fn (Blueprint $table) => DeadLetterSchema::blueprint($table)); + } + + /** @param array $options */ + public function replay(array $options = []): int + { + /** @var Kernel $kernel */ + $kernel = $this->app()->make(Kernel::class); + + return $kernel->call('firefly:messaging:dead-letters:replay', $options); + } + + public function replayOutput(): string + { + /** @var Kernel $kernel */ + $kernel = $this->app()->make(Kernel::class); + + return $kernel->output(); + } +} diff --git a/packages/scheduling-database/.gitattributes b/packages/scheduling-database/.gitattributes new file mode 100644 index 00000000..538b69a4 --- /dev/null +++ b/packages/scheduling-database/.gitattributes @@ -0,0 +1,2 @@ +/tests export-ignore +/.gitattributes export-ignore diff --git a/packages/scheduling-database/LICENSE b/packages/scheduling-database/LICENSE new file mode 100644 index 00000000..2240005d --- /dev/null +++ b/packages/scheduling-database/LICENSE @@ -0,0 +1,204 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +Copyright 2026 Firefly Software Solutions Inc. diff --git a/packages/scheduling-database/README.md b/packages/scheduling-database/README.md new file mode 100644 index 00000000..43876568 --- /dev/null +++ b/packages/scheduling-database/README.md @@ -0,0 +1,10 @@ +# firefly/scheduling-database + +LaraFly's database-backed `DistributedLock` providers for `#[Scheduled(lock: …)]`: `DatabaseLeaseLock` +(`firefly.scheduling.lock.provider=database`), a lease row per lock name in `firefly_scheduling_locks` on any Laravel +database connection, honouring the TTL on every driver; and `MySqlNamedLock` (`firefly.scheduling.lock.provider=mysql`), +MySQL and MariaDB `GET_LOCK`/`RELEASE_LOCK` named locks held for the connection's session. Installing the package is +inert until one of the two providers is selected. See +[docs/modules/scheduling-database.md](../../docs/modules/scheduling-database.md). + +Apache-2.0 © Firefly Software Solutions Inc. diff --git a/packages/scheduling-database/cache/firefly-scheduling-database-components.php b/packages/scheduling-database/cache/firefly-scheduling-database-components.php new file mode 100644 index 00000000..35cf26cc --- /dev/null +++ b/packages/scheduling-database/cache/firefly-scheduling-database-components.php @@ -0,0 +1,50 @@ + [ + 'class' => 'Firefly\\Scheduling\\Database\\SchedulingDatabaseAutoConfiguration', + 'stereotype' => 'configuration', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 900, + 'qualifier' => null, + 'interfaces' => [ + ], + 'beans' => [ + 0 => [ + 'method' => 'databaseLeaseLock', + 'returns' => 'Firefly\\Scheduling\\Lock\\DistributedLock', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Config\\Config', + 1 => 'Illuminate\\Database\\ConnectionResolverInterface', + ], + ], + 1 => [ + 'method' => 'mySqlNamedLock', + 'returns' => 'Firefly\\Scheduling\\Lock\\DistributedLock', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Config\\Config', + 1 => 'Illuminate\\Database\\ConnectionResolverInterface', + ], + ], + ], + 'lazy' => false, + 'dependencies' => [ + ], + ], +]; diff --git a/packages/scheduling-database/cache/firefly-scheduling-database-context.php b/packages/scheduling-database/cache/firefly-scheduling-database-context.php new file mode 100644 index 00000000..b793ff87 --- /dev/null +++ b/packages/scheduling-database/cache/firefly-scheduling-database-context.php @@ -0,0 +1,47 @@ + [ + 'class' => 'Firefly\\Scheduling\\Database\\SchedulingDatabaseAutoConfiguration', + 'postConstruct' => [ + ], + 'preDestroy' => [ + ], + 'listeners' => [ + ], + 'conditions' => [ + ], + 'beanConditions' => [ + 0 => [ + 'method' => 'databaseLeaseLock', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.scheduling.lock.provider', + 1 => 'database', + 2 => false, + ], + ], + ], + ], + 1 => [ + 'method' => 'mySqlNamedLock', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.scheduling.lock.provider', + 1 => 'mysql', + 2 => false, + ], + ], + ], + ], + ], + ], +]; diff --git a/packages/scheduling-database/composer.json b/packages/scheduling-database/composer.json new file mode 100644 index 00000000..029607a3 --- /dev/null +++ b/packages/scheduling-database/composer.json @@ -0,0 +1,60 @@ +{ + "name": "firefly/scheduling-database", + "description": "LaraFly scheduling database adapters: DatabaseLeaseLock, a DistributedLock over a portable firefly_scheduling_locks lease table on any Laravel database connection (firefly.scheduling.lock.provider=database), and MySqlNamedLock over GET_LOCK/RELEASE_LOCK for MySQL and MariaDB (firefly.scheduling.lock.provider=mysql).", + "type": "library", + "license": "Apache-2.0", + "homepage": "https://github.com/fireflyframework/fireflyframework-php", + "authors": [ + { + "name": "Firefly Software Solutions Inc.", + "homepage": "https://github.com/fireflyframework" + } + ], + "keywords": [ + "firefly", + "laravel", + "scheduling", + "database" + ], + "support": { + "issues": "https://github.com/fireflyframework/fireflyframework-php/issues", + "source": "https://github.com/fireflyframework/fireflyframework-php/tree/main/packages/scheduling-database" + }, + "require": { + "php": "^8.3", + "firefly/autoconfigure": "*@dev", + "firefly/config": "*@dev", + "firefly/container": "*@dev", + "firefly/context": "*@dev", + "firefly/kernel": "*@dev", + "firefly/scheduling": "*@dev", + "illuminate/contracts": "^13.0", + "illuminate/database": "^13.0", + "illuminate/support": "^13.0" + }, + "extra": { + "laravel": { + "providers": [ + "Firefly\\Scheduling\\Database\\SchedulingDatabaseServiceProvider", + "Firefly\\Scheduling\\Database\\SchedulingDatabaseBootServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "26.x-dev" + } + }, + "autoload": { + "psr-4": { + "Firefly\\Scheduling\\Database\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Firefly\\Scheduling\\Database\\Tests\\": "tests/" + } + }, + "minimum-stability": "stable", + "config": { + "sort-packages": true + } +} diff --git a/packages/scheduling-database/database/migrations/2026_10_12_000100_create_firefly_scheduling_locks_table.php b/packages/scheduling-database/database/migrations/2026_10_12_000100_create_firefly_scheduling_locks_table.php new file mode 100644 index 00000000..ceec46d7 --- /dev/null +++ b/packages/scheduling-database/database/migrations/2026_10_12_000100_create_firefly_scheduling_locks_table.php @@ -0,0 +1,21 @@ + LockSchema::blueprint($table)); + } + + public function down(): void + { + Schema::dropIfExists(LockSchema::TABLE); + } +}; diff --git a/packages/scheduling-database/src/DatabaseLeaseLock.php b/packages/scheduling-database/src/DatabaseLeaseLock.php new file mode 100644 index 00000000..c18843e1 --- /dev/null +++ b/packages/scheduling-database/src/DatabaseLeaseLock.php @@ -0,0 +1,122 @@ + name => the locked_by token of this instance's live acquisition */ + private array $held = []; + + /** + * @param (Closure(): DateTimeImmutable)|null $clock + */ + public function __construct( + private readonly ConnectionResolverInterface $connections, + private readonly ?string $connection = null, + ?string $owner = null, + private readonly ?Closure $clock = null, + ) { + $host = gethostname(); + $pid = getmypid(); + $this->owner = substr($owner ?? ((is_string($host) && $host !== '' ? $host : 'host').':'.($pid === false ? 0 : $pid)), 0, 200); + } + + public function owner(): string + { + return $this->owner; + } + + public function tryAcquire(string $name, float $ttlSeconds): bool + { + if (strlen($name) > self::MAX_NAME_LENGTH) { + throw new InvalidArgumentException(sprintf('A lock name is at most %d characters; got %d.', self::MAX_NAME_LENGTH, strlen($name))); + } + + $db = $this->db(); + $now = ($this->clock === null ? new DateTimeImmutable : ($this->clock)())->setTimezone(new DateTimeZone('UTC')); + $token = $this->owner.'/'.bin2hex(random_bytes(4)); + $lease = [ + 'locked_by' => $token, + 'locked_until' => self::format(self::plus($now, $ttlSeconds)), + 'locked_at' => self::format($now), + ]; + + $taken = $db->table(LockSchema::TABLE) + ->where('name', $name) + ->where('locked_until', '<=', self::format($now)) + ->update($lease); + + if ($taken !== 1) { + try { + $db->transaction(static fn () => $db->table(LockSchema::TABLE)->insert(['name' => $name, ...$lease])); + } catch (UniqueConstraintViolationException) { + return false; + } + } + + $this->held[$name] = $token; + + return true; + } + + public function release(string $name): void + { + $token = $this->held[$name] ?? null; + if ($token === null) { + return; + } + + unset($this->held[$name]); + $this->db()->table(LockSchema::TABLE)->where('name', $name)->where('locked_by', $token)->delete(); + } + + private function db(): ConnectionInterface + { + return $this->connections->connection($this->connection); + } + + private static function plus(DateTimeImmutable $instant, float $seconds): DateTimeImmutable + { + $milliseconds = max(1, (int) round($seconds * 1000)); + $interval = new DateInterval('PT'.intdiv($milliseconds, 1000).'S'); + $interval->f = ($milliseconds % 1000) / 1000; + + return $instant->add($interval); + } + + private static function format(DateTimeImmutable $instant): string + { + return $instant->format('Y-m-d H:i:s.v'); + } +} diff --git a/packages/scheduling-database/src/LockSchema.php b/packages/scheduling-database/src/LockSchema.php new file mode 100644 index 00000000..e6507652 --- /dev/null +++ b/packages/scheduling-database/src/LockSchema.php @@ -0,0 +1,24 @@ +string('name')->primary(); + $table->string('locked_by'); + $table->dateTime('locked_until', 3); + $table->dateTime('locked_at', 3); + } +} diff --git a/packages/scheduling-database/src/MySqlNamedLock.php b/packages/scheduling-database/src/MySqlNamedLock.php new file mode 100644 index 00000000..25327b3d --- /dev/null +++ b/packages/scheduling-database/src/MySqlNamedLock.php @@ -0,0 +1,63 @@ +` (48 characters), deterministic across nodes. + * GET_LOCK answers 1 (acquired), 0 (held elsewhere) or NULL (an error): only 1 is an acquisition. + */ +final class MySqlNamedLock implements DistributedLock +{ + public const int MAX_NAME_LENGTH = 64; + + public function __construct( + private readonly ConnectionResolverInterface $connections, + private readonly ?string $connection = null, + ) {} + + public static function key(string $name): string + { + return strlen($name) <= self::MAX_NAME_LENGTH ? $name : 'firefly:'.sha1($name); + } + + public function tryAcquire(string $name, float $ttlSeconds): bool + { + $row = $this->db()->selectOne('select get_lock(?, 0) as locked', [self::key($name)]); + $locked = is_object($row) ? (get_object_vars($row)['locked'] ?? null) : null; + + return is_numeric($locked) && (int) $locked === 1; + } + + public function release(string $name): void + { + $this->db()->selectOne('select release_lock(?) as released', [self::key($name)]); + } + + private function db(): Connection + { + $connection = $this->connections->connection($this->connection); + + if (! $connection instanceof Connection || ! in_array($connection->getDriverName(), ['mysql', 'mariadb'], true)) { + throw new ConfigurationException(sprintf( + 'firefly.scheduling.lock.provider=mysql needs a mysql or mariadb connection; "%s" is %s. Set ' + .'firefly.scheduling.lock.mysql.connection, or use provider=database, which works on every driver.', + $this->connection ?? 'the default connection', + $connection instanceof Connection ? $connection->getDriverName() : get_debug_type($connection), + )); + } + + return $connection; + } +} diff --git a/packages/scheduling-database/src/SchedulingDatabaseAutoConfiguration.php b/packages/scheduling-database/src/SchedulingDatabaseAutoConfiguration.php new file mode 100644 index 00000000..086a4db5 --- /dev/null +++ b/packages/scheduling-database/src/SchedulingDatabaseAutoConfiguration.php @@ -0,0 +1,49 @@ +get('firefly.scheduling.lock.database.connection') === null + ? null + : $config->string('firefly.scheduling.lock.database.connection'); + + return new DatabaseLeaseLock($connections, $connection === '' ? null : $connection); + } + + #[Bean] + #[ConditionalOnProperty(name: 'firefly.scheduling.lock.provider', havingValue: 'mysql')] + public function mySqlNamedLock(Config $config, ConnectionResolverInterface $connections): DistributedLock + { + // The same null-or-blank-means-default rule as databaseLeaseLock(): an unset env() must not fail the boot. + $connection = $config->get('firefly.scheduling.lock.mysql.connection') === null + ? null + : $config->string('firefly.scheduling.lock.mysql.connection'); + + return new MySqlNamedLock($connections, $connection === '' ? null : $connection); + } +} diff --git a/packages/scheduling-database/src/SchedulingDatabaseBootServiceProvider.php b/packages/scheduling-database/src/SchedulingDatabaseBootServiceProvider.php new file mode 100644 index 00000000..dd4c7b92 --- /dev/null +++ b/packages/scheduling-database/src/SchedulingDatabaseBootServiceProvider.php @@ -0,0 +1,20 @@ +app->make('config')->get('firefly.scheduling.lock.provider') === 'database') { + $this->loadMigrationsFrom(self::MIGRATIONS); + } + } +} diff --git a/packages/scheduling-database/src/SchedulingDatabaseServiceProvider.php b/packages/scheduling-database/src/SchedulingDatabaseServiceProvider.php new file mode 100644 index 00000000..b08f8b09 --- /dev/null +++ b/packages/scheduling-database/src/SchedulingDatabaseServiceProvider.php @@ -0,0 +1,26 @@ + dirname(__DIR__).'/src']; + $cache = dirname(__DIR__).'/cache'; + + $freshComponents = sys_get_temp_dir().'/firefly-scheduling-database-fresh-c-'.bin2hex(random_bytes(6)).'.php'; + $freshContext = sys_get_temp_dir().'/firefly-scheduling-database-fresh-x-'.bin2hex(random_bytes(6)).'.php'; + + try { + (new AutoConfigManifestCompiler)->write($src, $freshComponents, $freshContext); + + expect(ComponentManifest::load($freshComponents))->toEqual(ComponentManifest::load($cache.'/firefly-scheduling-database-components.php')) + ->and(ContextManifest::load($freshContext))->toEqual(ContextManifest::load($cache.'/firefly-scheduling-database-context.php')); + } finally { + @unlink($freshComponents); + @unlink($freshContext); + } +}); diff --git a/packages/scheduling-database/tests/DatabaseLeaseLockTest.php b/packages/scheduling-database/tests/DatabaseLeaseLockTest.php new file mode 100644 index 00000000..ccef8a2c --- /dev/null +++ b/packages/scheduling-database/tests/DatabaseLeaseLockTest.php @@ -0,0 +1,114 @@ +up(); +}); + +/** A lock instance ("node") named $owner whose clock reads $now. */ +function leaseLockAt(string $owner, string $now): DatabaseLeaseLock +{ + /** @var ConnectionResolverInterface $connections */ + $connections = App::make(ConnectionResolverInterface::class); + + return new DatabaseLeaseLock($connections, null, $owner, static fn (): DateTimeImmutable => new DateTimeImmutable($now, new DateTimeZone('UTC'))); +} + +it('grants a name to one node at a time and frees it on release', function () { + $a = leaseLockAt('node-a', '2026-10-12 10:00:00'); + $b = leaseLockAt('node-b', '2026-10-12 10:00:01'); + + expect($a->tryAcquire('reconcile', 30))->toBeTrue() + ->and($b->tryAcquire('reconcile', 30))->toBeFalse() + ->and($b->tryAcquire('other-job', 30))->toBeTrue() + ->and(DB::table(LockSchema::TABLE)->where('name', 'reconcile')->value('locked_by'))->toStartWith('node-a/'); + + $a->release('reconcile'); + + expect(DB::table(LockSchema::TABLE)->where('name', 'reconcile')->exists())->toBeFalse() + ->and($b->tryAcquire('reconcile', 30))->toBeTrue(); +}); + +it('hands an ended lease to the next node, and the late holder cannot release it', function () { + $a = leaseLockAt('node-a', '2026-10-12 10:00:00'); + expect($a->tryAcquire('reconcile', 1.5))->toBeTrue(); + + expect(leaseLockAt('node-b', '2026-10-12 10:00:01.499')->tryAcquire('reconcile', 30))->toBeFalse(); + + $b = leaseLockAt('node-b', '2026-10-12 10:00:01.500'); + expect($b->tryAcquire('reconcile', 30))->toBeTrue() + ->and(DB::table(LockSchema::TABLE)->where('name', 'reconcile')->value('locked_until'))->toBe('2026-10-12 10:00:31.500'); + + // a's job finally ends: its release must not drop b's lease. + $a->release('reconcile'); + expect(DB::table(LockSchema::TABLE)->where('name', 'reconcile')->value('locked_by'))->toStartWith('node-b/'); +}); + +it('refuses a name longer than the column instead of truncating it', function () { + expect(fn () => leaseLockAt('a', '2026-10-12 10:00:00')->tryAcquire(str_repeat('x', 256), 1))->toThrow(InvalidArgumentException::class, '255'); +}); + +it('keeps a caller transaction usable when the lock is already held', function () { + leaseLockAt('node-a', '2026-10-12 10:00:00')->tryAcquire('reconcile', 30); + + DB::transaction(function (): void { + expect(leaseLockAt('node-b', '2026-10-12 10:00:01')->tryAcquire('reconcile', 30))->toBeFalse() + ->and(DB::selectOne('select 1 as one'))->not->toBeNull(); + }); +}); + +it('releasing a name it does not hold is a no-op', function () { + leaseLockAt('node-a', '2026-10-12 10:00:00')->release('never-acquired'); + + expect(DB::table(LockSchema::TABLE)->count())->toBe(0); +}); + +it('the bean uses the default connection when the connection key is unset, null or blank', function (array $lock) { + /** @var ConnectionResolverInterface $connections */ + $connections = App::make(ConnectionResolverInterface::class); + $config = new Config(new Repository(['firefly' => ['scheduling' => ['lock' => ['provider' => 'database', ...$lock]]]])); + + $bean = (new SchedulingDatabaseAutoConfiguration)->databaseLeaseLock($config, $connections); + + expect($bean)->toBeInstanceOf(DatabaseLeaseLock::class) + ->and($bean->tryAcquire('reconcile', 30))->toBeTrue() + ->and(DB::table(LockSchema::TABLE)->where('name', 'reconcile')->exists())->toBeTrue(); +})->with([ + 'unset' => [[]], + 'null' => [['database' => ['connection' => null]]], + 'blank' => [['database' => ['connection' => '']]], +]); + +it('the bean takes the lease on the connection the key names', function () { + config()->set('database.connections.locks', ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']); + Schema::connection('locks')->create(LockSchema::TABLE, fn (Blueprint $table) => LockSchema::blueprint($table)); + /** @var ConnectionResolverInterface $connections */ + $connections = App::make(ConnectionResolverInterface::class); + $config = new Config(new Repository(['firefly' => ['scheduling' => ['lock' => ['provider' => 'database', 'database' => ['connection' => 'locks']]]]])); + + expect((new SchedulingDatabaseAutoConfiguration)->databaseLeaseLock($config, $connections)->tryAcquire('reconcile', 30))->toBeTrue() + ->and(DB::connection('locks')->table(LockSchema::TABLE)->count())->toBe(1) + ->and(DB::table(LockSchema::TABLE)->count())->toBe(0); +}); diff --git a/packages/scheduling-database/tests/Integration/DatabaseLeaseLockIntegrationTest.php b/packages/scheduling-database/tests/Integration/DatabaseLeaseLockIntegrationTest.php new file mode 100644 index 00000000..7056622e --- /dev/null +++ b/packages/scheduling-database/tests/Integration/DatabaseLeaseLockIntegrationTest.php @@ -0,0 +1,76 @@ + new DateTimeImmutable($now, new DateTimeZone('UTC'))); +} + +/** Runs one direction of the package's anonymous lease-table migration, on the default connection. */ +function leaseIntegrationMigrate(string $direction): void +{ + $migration = require dirname(__DIR__, 2).'/database/migrations/2026_10_12_000100_create_firefly_scheduling_locks_table.php'; + + if (! $migration instanceof Migration || ! method_exists($migration, 'up') || ! method_exists($migration, 'down')) { + throw new RuntimeException('Expected an executable migration.'); + } + + $direction === 'down' ? $migration->down() : $migration->up(); +} + +foreach (IntegrationDsn::lanes() as $lane) { + it("excludes, expires and hands over leases on {$lane}", function () use ($lane): void { + $overrides = $lane === 'sqlite' ? ['database' => IntegrationDsn::sqlitePath('lease-lock')] : []; + config([ + 'database.connections.node_a' => IntegrationDsn::connectionConfig($lane, $overrides), + 'database.connections.node_b' => IntegrationDsn::connectionConfig($lane, $overrides), + ]); + DB::setDefaultConnection('node_a'); + leaseIntegrationMigrate('down'); + leaseIntegrationMigrate('up'); + + try { + $a = leaseIntegrationLock('node_a', 'node-a', '2026-10-12 10:00:00'); + expect($a->tryAcquire('reconcile', 2))->toBeTrue() + ->and(leaseIntegrationLock('node_b', 'node-b', '2026-10-12 10:00:01.999')->tryAcquire('reconcile', 30))->toBeFalse(); + + DB::connection('node_b')->transaction(function (): void { + expect(leaseIntegrationLock('node_b', 'node-b', '2026-10-12 10:00:01')->tryAcquire('reconcile', 30))->toBeFalse() + ->and(DB::connection('node_b')->selectOne('select 1 as one'))->not->toBeNull(); + }); + + $b = leaseIntegrationLock('node_b', 'node-b', '2026-10-12 10:00:02'); + expect($b->tryAcquire('reconcile', 30))->toBeTrue(); + $a->release('reconcile'); + expect(DB::connection('node_b')->table(LockSchema::TABLE)->where('name', 'reconcile')->value('locked_by'))->toStartWith('node-b/'); + + $b->release('reconcile'); + expect(DB::connection('node_a')->table(LockSchema::TABLE)->count())->toBe(0) + ->and(leaseIntegrationLock('node_a', 'node-a', '2026-10-12 10:00:03')->tryAcquire('reconcile', 30))->toBeTrue(); + } finally { + leaseIntegrationMigrate('down'); + DB::connection('node_a')->disconnect(); + DB::connection('node_b')->disconnect(); + } + })->group('integration')->skip(IntegrationDsn::skipReason($lane) !== null, (string) IntegrationDsn::skipReason($lane)); +} diff --git a/packages/scheduling-database/tests/Integration/MySqlNamedLockIntegrationTest.php b/packages/scheduling-database/tests/Integration/MySqlNamedLockIntegrationTest.php new file mode 100644 index 00000000..71a39960 --- /dev/null +++ b/packages/scheduling-database/tests/Integration/MySqlNamedLockIntegrationTest.php @@ -0,0 +1,52 @@ + IntegrationDsn::connectionConfig($lane), + 'database.connections.session_b' => IntegrationDsn::connectionConfig($lane), + ]); + /** @var ConnectionResolverInterface $connections */ + $connections = App::make(ConnectionResolverInterface::class); + $a = new MySqlNamedLock($connections, 'session_a'); + $b = new MySqlNamedLock($connections, 'session_b'); + $name = 'firefly:test:'.bin2hex(random_bytes(4)); + $long = $name.':'.str_repeat('LongJobName', 10); + + expect($a->tryAcquire($name, 30))->toBeTrue() + ->and($b->tryAcquire($name, 30))->toBeFalse() + ->and($a->tryAcquire($long, 30))->toBeTrue() + ->and($b->tryAcquire($long, 30))->toBeFalse(); + + $a->release($name); + expect($b->tryAcquire($name, 30))->toBeTrue(); + $b->release($name); + + // The session ends: the server frees every named lock it held (as soon as it notices the closed socket). + DB::connection('session_a')->disconnect(); + DB::purge('session_a'); + $freed = false; + $deadline = microtime(true) + 3; + while (! $freed && microtime(true) < $deadline) { + $freed = $b->tryAcquire($long, 30); + if (! $freed) { + usleep(100_000); + } + } + expect($freed)->toBeTrue(); + $b->release($long); + DB::connection('session_b')->disconnect(); + })->group('integration')->skip(IntegrationDsn::skipReason($lane) !== null, (string) IntegrationDsn::skipReason($lane)); +} diff --git a/packages/scheduling-database/tests/LockProviderSelectionBootTest.php b/packages/scheduling-database/tests/LockProviderSelectionBootTest.php new file mode 100644 index 00000000..876dfec9 --- /dev/null +++ b/packages/scheduling-database/tests/LockProviderSelectionBootTest.php @@ -0,0 +1,101 @@ + ['scheduling' => $provider === null ? [] : ['lock' => ['provider' => $provider]]]], + [SchedulingServiceProvider::class, SchedulingPostgresServiceProvider::class, SchedulingDatabaseServiceProvider::class], + bindings: [ + CacheRepositoryContract::class => new CacheRepository(new ArrayStore), + CacheFactoryContract::class => new class implements CacheFactoryContract + { + public function store($name = null) + { + return new CacheRepository(new ArrayStore); + } + }, + ConnectionResolverInterface::class => new class($connection) implements ConnectionResolverInterface + { + public function __construct(private readonly SQLiteConnection $connection) {} + + public function connection($name = null): SQLiteConnection + { + return $this->connection; + } + + public function getDefaultConnection(): string + { + return 'default'; + } + + public function setDefaultConnection($name): void {} + }, + ], + ); +} + +/** @return list */ +function lockSelectionBeans(Application $app): array +{ + /** @var FireflyKernel $kernel */ + $kernel = $app->make(FireflyKernel::class); + + $beans = []; + foreach ($kernel->context()->definitions->all() as $definition) { + foreach ($definition->descriptor->beans as $bean) { + if ($bean->returns === DistributedLock::class) { + $beans[] = $definition->class().'::'.$bean->method; + } + } + } + sort($beans); + + return $beans; +} + +it('resolves exactly the lock the provider names', function (?string $provider, string $class, string $bean) { + $app = lockSelectionApp($provider); + /** @var ApplicationContext $context */ + $context = $app->make(ApplicationContext::class); + + // The exact class, not instanceof: every lock is final, and a dataset string is not a class-string to PHPStan. + expect(get_debug_type($context->get(DistributedLock::class)))->toBe($class) + ->and(lockSelectionBeans($app))->toBe([$bean]); +})->with([ + 'database' => ['database', DatabaseLeaseLock::class, SchedulingDatabaseAutoConfiguration::class.'::databaseLeaseLock'], + 'mysql' => ['mysql', MySqlNamedLock::class, SchedulingDatabaseAutoConfiguration::class.'::mySqlNamedLock'], + 'postgres' => ['postgres', PgAdvisoryLock::class, PgAdvisoryLockAutoConfiguration::class.'::distributedLock'], + 'cache' => ['cache', CacheLock::class, SchedulingAutoConfiguration::class.'::distributedLock'], + 'none' => [null, NoneLock::class, SchedulingAutoConfiguration::class.'::distributedLock'], +]); diff --git a/packages/scheduling-database/tests/MySqlNamedLockTest.php b/packages/scheduling-database/tests/MySqlNamedLockTest.php new file mode 100644 index 00000000..9296b623 --- /dev/null +++ b/packages/scheduling-database/tests/MySqlNamedLockTest.php @@ -0,0 +1,62 @@ +connection; + } + + public function getDefaultConnection(): string + { + return 'default'; + } + + public function setDefaultConnection($name): void {} + }; +} + +it('asks GET_LOCK with a zero timeout and releases with RELEASE_LOCK', function () { + $connection = new MySqlConnection(new PDO('sqlite::memory:'), 'pretend', '', ['driver' => 'mysql', 'name' => 'pretend']); + $lock = new MySqlNamedLock(namedLockResolver($connection)); + + $log = $connection->pretend(function () use ($lock): void { + // pretend() answers nothing, which GET_LOCK reports as an error (NULL): no lock. + expect($lock->tryAcquire('reconcile', 30))->toBeFalse(); + $lock->release('reconcile'); + }); + + // Under pretend() Laravel logs the SQL with its bindings substituted; the bindings array proves the name travelled + // as a bound parameter, never concatenated into the statement. + expect(array_column($log, 'query'))->toBe(["select get_lock('reconcile', 0) as locked", "select release_lock('reconcile') as released"]) + ->and(array_column($log, 'bindings'))->toBe([['reconcile'], ['reconcile']]); +}); + +it('keeps a name up to 64 bytes and hashes a longer one into the MySQL limit', function () { + $long = str_repeat('App\\Jobs\\VeryLongNamespace\\', 4).'Reconcile::run'; + + expect(MySqlNamedLock::key('reconcile'))->toBe('reconcile') + ->and(MySqlNamedLock::key(str_repeat('x', 64)))->toBe(str_repeat('x', 64)) + ->and(MySqlNamedLock::key($long))->toBe('firefly:'.sha1($long)) + ->and(strlen(MySqlNamedLock::key($long)))->toBe(48); +}); + +it('refuses a connection that is not MySQL or MariaDB', function () { + $lock = new MySqlNamedLock(namedLockResolver(new SQLiteConnection(new PDO('sqlite::memory:'), ':memory:'))); + + expect(fn () => $lock->tryAcquire('reconcile', 30))->toThrow(ConfigurationException::class, 'firefly.scheduling.lock.provider=mysql'); +}); diff --git a/packages/scheduling-database/tests/ReflectionFreeSchedulingDatabaseTest.php b/packages/scheduling-database/tests/ReflectionFreeSchedulingDatabaseTest.php new file mode 100644 index 00000000..c29f0691 --- /dev/null +++ b/packages/scheduling-database/tests/ReflectionFreeSchedulingDatabaseTest.php @@ -0,0 +1,26 @@ + basenames under $dir whose contents reference boot-time reflection */ +function schedulingDatabaseReflectionHits(string $dir): array +{ + $hits = []; + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)); + /** @var SplFileInfo $file */ + foreach ($files as $file) { + if (! $file->isFile() || $file->getExtension() !== 'php') { + continue; + } + if (preg_match('/ReflectionClass|ReflectionMethod|getAttributes/', (string) file_get_contents((string) $file->getRealPath())) === 1) { + $hits[] = $file->getBasename(); + } + } + sort($hits); + + return $hits; +} + +it('the firefly/scheduling-database adapter uses no boot-time reflection', function () { + expect(schedulingDatabaseReflectionHits(__DIR__.'/../src'))->toBe([]); +}); diff --git a/packages/scheduling-database/tests/SchedulingDatabaseBootDiscoveryTest.php b/packages/scheduling-database/tests/SchedulingDatabaseBootDiscoveryTest.php new file mode 100644 index 00000000..3c90cd0f --- /dev/null +++ b/packages/scheduling-database/tests/SchedulingDatabaseBootDiscoveryTest.php @@ -0,0 +1,39 @@ +instance('config', new Repository(['firefly' => ['scheduling' => $provider === null ? [] : ['lock' => ['provider' => $provider]]]])); + $app->instance('files', new Filesystem); + $app->register(DatabaseServiceProvider::class); + $app->register(MigrationServiceProvider::class); + (new SchedulingDatabaseBootServiceProvider($app))->boot(); + /** @var Migrator $migrator */ + $migrator = $app->make('migrator'); + + $paths = []; + foreach ($migrator->paths() as $path) { + $paths[] = realpath($path); + } + + expect($paths)->toBe($loaded ? [realpath(dirname(__DIR__).'/database/migrations')] : []); + $app->flush(); +})->with([ + 'unset' => [null, false], + 'cache' => ['cache', false], + 'postgres' => ['postgres', false], + 'database' => ['database', true], +]); diff --git a/packages/scheduling-postgres/tests/PgAdvisoryLockIntegrationTest.php b/packages/scheduling-postgres/tests/PgAdvisoryLockIntegrationTest.php index 8ca146ce..9b011e22 100644 --- a/packages/scheduling-postgres/tests/PgAdvisoryLockIntegrationTest.php +++ b/packages/scheduling-postgres/tests/PgAdvisoryLockIntegrationTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Firefly\Scheduling\Postgres\PgAdvisoryLock; +use Firefly\Testing\Integration\IntegrationDsn; use Illuminate\Support\Facades\DB; use Orchestra\Testbench\TestCase; @@ -30,25 +31,12 @@ $b->release($name); DB::connection('firefly_pg_b')->disconnect(); })->skip( - ! extension_loaded('pdo_pgsql') || getenv('FIREFLY_PG_DSN') === false, - 'requires pdo_pgsql + FIREFLY_PG_DSN', -); + IntegrationDsn::skipReason('pgsql') !== null, + (string) IntegrationDsn::skipReason('pgsql'), +)->group('integration'); -/** @return array a Laravel pgsql connection config parsed from FIREFLY_PG_DSN */ +/** @return array a Laravel pgsql connection config from FIREFLY_PG_DSN */ function firefly_pg_config(): array { - $parts = []; - foreach (explode(';', (string) getenv('FIREFLY_PG_DSN')) as $kv) { - [$k, $v] = array_pad(explode('=', $kv, 2), 2, ''); - $parts[$k] = $v; - } - - return [ - 'driver' => 'pgsql', - 'host' => $parts['host'] ?? '127.0.0.1', - 'port' => $parts['port'] ?? '5432', - 'database' => $parts['dbname'] ?? 'postgres', - 'username' => $parts['user'] ?? 'postgres', - 'password' => $parts['password'] ?? '', - ]; + return IntegrationDsn::connectionConfig('pgsql'); } diff --git a/packages/scheduling/README.md b/packages/scheduling/README.md index 2b845c68..cc1c4c27 100644 --- a/packages/scheduling/README.md +++ b/packages/scheduling/README.md @@ -5,6 +5,7 @@ zero-coordination default) and `CacheLock` (over Laravel's atomic `Cache::lock`) attribute discovered by the package's SOLE reflection site (`ScheduledScanner`) into a compiled, require-loaded `ScheduledManifest`; and a `ScheduleWiringPass` that lazily registers native Laravel scheduled tasks — each task body wrapped in a lock guard (acquire → run → release) and its exceptions logged, never propagated. The -`postgres` lock backend (`PgAdvisoryLock`) ships in the separate `firefly/scheduling-postgres` adapter package. +`postgres` lock backend (`PgAdvisoryLock`) ships in `firefly/scheduling-postgres`; the `database` +(`DatabaseLeaseLock`) and `mysql` (`MySqlNamedLock`) backends ship in `firefly/scheduling-database`. Apache-2.0 © Firefly Software Solutions Inc. diff --git a/packages/scheduling/src/Lock/DistributedLock.php b/packages/scheduling/src/Lock/DistributedLock.php index 8e5b429a..d7214915 100644 --- a/packages/scheduling/src/Lock/DistributedLock.php +++ b/packages/scheduling/src/Lock/DistributedLock.php @@ -7,7 +7,8 @@ /** * The distributed-lock port (a ShedLock analog): the ScheduleWiringPass acquires a named lock before running a * scheduled task body and releases it after, so a task marked #[Scheduled(lock: ...)] runs on at most one node - * per tick. NoneLock (default) and CacheLock ship here; PgAdvisoryLock ships in firefly/scheduling-postgres. + * per tick. NoneLock (default) and CacheLock ship here; PgAdvisoryLock ships in firefly/scheduling-postgres, + * DatabaseLeaseLock and MySqlNamedLock in firefly/scheduling-database. */ interface DistributedLock { diff --git a/packages/scheduling/tests/Integration/CacheLockRedisIntegrationTest.php b/packages/scheduling/tests/Integration/CacheLockRedisIntegrationTest.php new file mode 100644 index 00000000..6ed2d128 --- /dev/null +++ b/packages/scheduling/tests/Integration/CacheLockRedisIntegrationTest.php @@ -0,0 +1,52 @@ + 'predis', + 'database.redis.default' => ['url' => IntegrationDsn::value(IntegrationDsn::REDIS)], + 'cache.stores.it_redis' => ['driver' => 'redis', 'connection' => 'default', 'lock_connection' => 'default'], + ]); + /** @var Repository $store */ + $store = Cache::store('it_redis'); + $a = new CacheLock($store, 'owner-a'); + $b = new CacheLock($store, 'owner-b'); + $name = 'firefly:test:'.bin2hex(random_bytes(4)); + + expect($a->tryAcquire($name, 30))->toBeTrue() + ->and($b->tryAcquire($name, 30))->toBeFalse(); + $b->release($name); + $redis = $store->getStore(); + if (! $redis instanceof LockProvider) { + throw new RuntimeException('The Redis cache store is expected to provide atomic locks.'); + } + // Owner tokens are enforced by Redis itself, not only by CacheLock's bookkeeping: a foreign token releases nothing. + expect($b->tryAcquire($name, 30))->toBeFalse() + ->and($redis->lock($name, 30, 'owner-b')->release())->toBeFalse() + ->and($b->tryAcquire($name, 30))->toBeFalse(); + $a->release($name); + expect($b->tryAcquire($name, 1))->toBeTrue(); + + usleep(1_300_000); + expect($a->tryAcquire($name, 30))->toBeTrue(); + $a->release($name); +})->group('integration')->skip( + IntegrationDsn::missing(IntegrationDsn::REDIS) || ! class_exists(Client::class), + 'Set FIREFLY_REDIS_URL (and install predis/predis) to run the Redis CacheLock integration test.', +); diff --git a/packages/testing/README.md b/packages/testing/README.md index 72f9b390..61856f3c 100644 --- a/packages/testing/README.md +++ b/packages/testing/README.md @@ -8,8 +8,9 @@ and `FireflyDatabaseTestCase`/`UsesSqliteMemory` for database-backed tests; web/ doubles for every one of Firefly's own ports (`RecordingEventPublisher`, `RecordingCommandBus`, `RecordingMessageBroker`, `RecordingTracer`, `FakeHealthIndicator`, and more); Firefly-flavored Pest expectations (`toHavePublished`/`toHaveHandledCommand`/`toBeUp`/`toHaveRecordedMetric`/ -`toBeProblemDetails`); a fixture layer (`FixtureRegistry`/`AggregateSeeder`/`ListenerSpy`); and a -`RequiresDocker` testcontainers hook for `@group('integration')` tests. `require-dev` only, no runtime +`toBeProblemDetails`); a fixture layer (`FixtureRegistry`/`AggregateSeeder`/`ListenerSpy`); a +`RequiresDocker` testcontainers hook; and `IntegrationDsn`, the one parser every `@group('integration')` test +reads its `FIREFLY_*` backend from. `require-dev` only, no runtime service provider. See [Testing](../../docs/modules/testing.md) for the full reference. diff --git a/packages/testing/composer.json b/packages/testing/composer.json index 4752b8fc..f62e6ccb 100644 --- a/packages/testing/composer.json +++ b/packages/testing/composer.json @@ -46,6 +46,7 @@ "illuminate/config": "^13.0", "illuminate/container": "^13.0", "illuminate/contracts": "^13.0", + "illuminate/database": "^13.0", "illuminate/http": "^13.0", "illuminate/routing": "^13.0", "illuminate/support": "^13.0", diff --git a/packages/testing/src/Integration/IntegrationDsn.php b/packages/testing/src/Integration/IntegrationDsn.php new file mode 100644 index 00000000..d34cd9bb --- /dev/null +++ b/packages/testing/src/Integration/IntegrationDsn.php @@ -0,0 +1,317 @@ + */ + public const array DATABASES = [ + 'pgsql' => ['driver' => 'pgsql', 'variable' => self::PGSQL, 'extension' => 'pdo_pgsql'], + 'mysql' => ['driver' => 'mysql', 'variable' => self::MYSQL, 'extension' => 'pdo_mysql'], + 'mariadb' => ['driver' => 'mariadb', 'variable' => self::MARIADB, 'extension' => 'pdo_mysql'], + 'sqlsrv' => ['driver' => 'sqlsrv', 'variable' => self::SQLSRV, 'extension' => 'pdo_sqlsrv'], + 'sqlite' => ['driver' => 'sqlite', 'variable' => null, 'extension' => 'pdo_sqlite'], + ]; + + /** @var array */ + private const array DEFAULT_PORTS = ['pgsql' => 5432, 'mysql' => 3306, 'mariadb' => 3306, 'sqlsrv' => 1433]; + + /** @var list the only keys a URL DSN's query string may set; host, port, user and password come from the authority */ + private const array URL_QUERY_EXTRAS = ['search_path', 'encrypt', 'trust_server_certificate', 'sslmode', 'charset', 'vhost']; + + private static ?string $sqliteDirectory = null; + + /** @return list */ + public static function lanes(): array + { + return array_keys(self::DATABASES); + } + + public static function value(string $variable): ?string + { + $value = getenv($variable); + + return is_string($value) && trim($value) !== '' ? trim($value) : null; + } + + public static function missing(string $variable): bool + { + return self::value($variable) === null; + } + + /** + * @return array + */ + public static function parse(string $dsn): array + { + $dsn = trim($dsn); + + if (preg_match('#^[a-z][a-z0-9+.\-]*://#i', $dsn) === 1) { + return self::parseUrl($dsn); + } + + $parts = []; + foreach (explode(';', $dsn) as $position => $pair) { + if (trim($pair) === '') { + continue; + } + if (! str_contains($pair, '=')) { + // Never echo the DSN or the segment: either may be (part of) a password. + throw new InvalidArgumentException(sprintf('Cannot read the DSN: segment %d is not a key=value pair (expected host=…;port=…;dbname=…;user=…;password=… or scheme://user:pass@host:port/db).', $position + 1)); + } + [$key, $value] = explode('=', $pair, 2); + $parts[strtolower(trim($key))] = trim($value); + } + + if (($parts['host'] ?? '') === '') { + throw new InvalidArgumentException('Cannot read the DSN: it names no host (expected host=…;port=…;dbname=…;user=…;password=…).'); + } + + return $parts; + } + + /** + * @return array|null + */ + public static function parts(string $variable): ?array + { + $value = self::value($variable); + + return $value === null ? null : self::parse($value); + } + + /** Why $lane cannot run here, or null when it can. */ + public static function skipReason(string $lane): ?string + { + $database = self::DATABASES[$lane] ?? throw new InvalidArgumentException(sprintf('Unknown integration lane [%s]; known: %s.', $lane, implode(', ', self::lanes()))); + + if (! extension_loaded($database['extension'])) { + return sprintf('requires ext-%s for the %s integration lane', $database['extension'], $lane); + } + + if ($database['variable'] !== null && self::missing($database['variable'])) { + return sprintf('Set %s (host=…;port=…;dbname=…;user=…;password=…) to run the %s integration lane.', $database['variable'], $lane); + } + + return null; + } + + /** + * @param array $overrides + * @return array + */ + public static function connectionConfig(string $lane, array $overrides = []): array + { + $database = self::DATABASES[$lane] ?? throw new InvalidArgumentException(sprintf('Unknown integration lane [%s].', $lane)); + + if ($database['variable'] === null) { + $path = $overrides['database'] ?? self::sqlitePath(); + + return array_replace(self::sqlite(is_string($path) ? $path : self::sqlitePath()), $overrides); + } + + return self::connectionConfigFor($database['driver'], $database['variable'], $overrides); + } + + /** + * A config for $driver from the DSN in $variable — the form a suite uses when it deliberately speaks one driver to + * another lane's server (feature-flags runs its `mysql` driver against the MariaDB server). + * + * @param array $overrides + * @return array + */ + public static function connectionConfigFor(string $driver, string $variable, array $overrides = []): array + { + $parts = self::parts($variable) ?? throw new InvalidArgumentException(sprintf('%s is not set.', $variable)); + + $config = [ + 'driver' => $driver, + 'host' => $parts['host'], + 'port' => (int) ($parts['port'] ?? self::DEFAULT_PORTS[$driver] ?? 0), + 'database' => $parts['dbname'] ?? $parts['database'] ?? 'firefly', + 'username' => $parts['user'] ?? $parts['username'] ?? '', + 'password' => $parts['password'] ?? '', + 'prefix' => '', + ]; + + $config = match ($driver) { + 'pgsql' => [...$config, 'charset' => 'utf8', 'search_path' => $parts['search_path'] ?? 'public'], + 'mysql', 'mariadb' => [...$config, 'charset' => 'utf8mb4', 'collation' => 'utf8mb4_unicode_ci'], + 'sqlsrv' => [...$config, 'charset' => 'utf8', 'encrypt' => $parts['encrypt'] ?? 'yes', 'trust_server_certificate' => $parts['trust_server_certificate'] ?? 'false'], + default => $config, + }; + + return array_replace($config, $overrides); + } + + /** + * A sqlite connection config over a FILE, created when missing (Laravel refuses a sqlite path that does not + * exist), in WAL mode with a busy timeout, so two OS processes can write it concurrently without SQLITE_BUSY. + * The file is created exclusively (O_EXCL, mode 0600) and a symlink at $path is refused, so a pre-planted link + * can never redirect the test database elsewhere. + * + * @return array + */ + public static function sqlite(string $path): array + { + if (is_link($path)) { + throw new InvalidArgumentException(sprintf('Refusing the sqlite path [%s]: it is a symbolic link.', $path)); + } + + if (! file_exists($path)) { + $handle = @fopen($path, 'x'); + if ($handle === false) { + throw new InvalidArgumentException(sprintf('Cannot create the sqlite file [%s] exclusively.', $path)); + } + fclose($handle); + chmod($path, 0600); + } elseif (! is_file($path)) { + throw new InvalidArgumentException(sprintf('Refusing the sqlite path [%s]: it is not a regular file.', $path)); + } + + return [ + 'driver' => 'sqlite', + 'database' => $path, + 'prefix' => '', + 'foreign_key_constraints' => true, + 'busy_timeout' => 10000, + 'journal_mode' => 'wal', + 'synchronous' => 'normal', + ]; + } + + /** + * A per-process temporary sqlite file inside a private (0700), unpredictably named directory created once per + * process; a test that spawns a second process hands it this same path. Stable for a given $name within the + * process, so a test can remove and recreate it. + */ + public static function sqlitePath(string $name = 'integration'): string + { + if (self::$sqliteDirectory === null || ! is_dir(self::$sqliteDirectory)) { + $directory = sys_get_temp_dir().'/firefly-it-'.bin2hex(random_bytes(8)); + if (! mkdir($directory, 0700) && ! is_dir($directory)) { + throw new InvalidArgumentException(sprintf('Cannot create the private sqlite directory [%s].', $directory)); + } + self::$sqliteDirectory = $directory; + } + + return self::$sqliteDirectory.'/'.preg_replace('/[^A-Za-z0-9_-]/', '_', $name).'.sqlite'; + } + + /** + * A standalone connection (no application): what a racer process or an app-less test uses. + * + * @param array $overrides + */ + public static function connect(string $lane, string $name = 'integration', array $overrides = []): Connection + { + $capsule = new Manager; + $capsule->addConnection(self::connectionConfig($lane, $overrides), $name); + + return $capsule->getConnection($name); + } + + public static function connectFor(string $driver, string $variable, string $name = 'integration'): Connection + { + $capsule = new Manager; + $capsule->addConnection(self::connectionConfigFor($driver, $variable), $name); + + return $capsule->getConnection($name); + } + + /** + * @return array{host: string, port: int, user: string, password: string, vhost: string} + */ + public static function rabbitmq(): array + { + $parts = self::parts(self::RABBITMQ) ?? throw new InvalidArgumentException(self::RABBITMQ.' is not set.'); + $vhost = $parts['vhost'] ?? ''; + + return [ + 'host' => $parts['host'], + 'port' => (int) ($parts['port'] ?? 5672), + 'user' => $parts['user'] ?? 'guest', + 'password' => $parts['password'] ?? 'guest', + 'vhost' => $vhost === '' ? '/' : $vhost, + ]; + } + + /** + * @return array + */ + private static function parseUrl(string $dsn): array + { + $url = parse_url($dsn); + + if (! is_array($url) || ($url['host'] ?? '') === '') { + throw new InvalidArgumentException('Cannot read the DSN: a URL DSN needs a host (scheme://user:pass@host:port/db).'); + } + + $parts = ['scheme' => strtolower($url['scheme'] ?? ''), 'host' => $url['host']]; + + if (isset($url['port'])) { + $parts['port'] = (string) $url['port']; + } + if (isset($url['user'])) { + $parts['user'] = rawurldecode($url['user']); + } + if (isset($url['pass'])) { + $parts['password'] = rawurldecode($url['pass']); + } + + $path = rawurldecode(ltrim($url['path'] ?? '', '/')); + if ($path !== '') { + $parts['dbname'] = $path; + $parts['vhost'] = $path; + } + + if (isset($url['query'])) { + parse_str($url['query'], $query); + foreach ($query as $key => $value) { + $key = strtolower((string) $key); + // The authority is authoritative: the query string only carries connection EXTRAS, never identity. + if (is_string($value) && in_array($key, self::URL_QUERY_EXTRAS, true)) { + $parts[$key] = $value; + } + } + } + + return $parts; + } +} diff --git a/packages/testing/tests/Integration/IntegrationDsnTest.php b/packages/testing/tests/Integration/IntegrationDsnTest.php new file mode 100644 index 00000000..f5d9fba8 --- /dev/null +++ b/packages/testing/tests/Integration/IntegrationDsnTest.php @@ -0,0 +1,184 @@ +toBe([ + 'host' => '127.0.0.1', + 'port' => '55432', + 'dbname' => 'firefly_w1', + 'user' => 'firefly', + 'password' => 'p=ss', + ]); +}); + +it('parses the URL form, decoding credentials, the path as database and vhost, and the query as extras', function () { + expect(IntegrationDsn::parse('amqp://guest:g%40st@127.0.0.1:55672/%2f'))->toBe([ + 'scheme' => 'amqp', + 'host' => '127.0.0.1', + 'port' => '55672', + 'user' => 'guest', + 'password' => 'g@st', + 'dbname' => '/', + 'vhost' => '/', + ])->and(IntegrationDsn::parse('sqlsrv://sa:Secret-1@db:1433/firefly?encrypt=no&trust_server_certificate=true'))->toBe([ + 'scheme' => 'sqlsrv', + 'host' => 'db', + 'port' => '1433', + 'user' => 'sa', + 'password' => 'Secret-1', + 'dbname' => 'firefly', + 'vhost' => 'firefly', + 'encrypt' => 'no', + 'trust_server_certificate' => 'true', + ]); +}); + +it('refuses a DSN it cannot read instead of falling back to localhost defaults', function (string $dsn) { + expect(fn () => IntegrationDsn::parse($dsn))->toThrow(InvalidArgumentException::class); +})->with([ + 'no equals sign' => ['127.0.0.1:5432'], + 'no host' => ['port=5432;dbname=x'], + 'url without host' => ['pgsql:///firefly'], +]); + +it('treats an unset or blank variable as missing', function () { + putenv('FIREFLY_TEST_DSN= '); + expect(IntegrationDsn::value('FIREFLY_TEST_DSN'))->toBeNull() + ->and(IntegrationDsn::missing('FIREFLY_TEST_DSN'))->toBeTrue() + ->and(IntegrationDsn::parts('FIREFLY_TEST_DSN'))->toBeNull(); + + putenv('FIREFLY_TEST_DSN=host=db;port=1'); + expect(IntegrationDsn::missing('FIREFLY_TEST_DSN'))->toBeFalse() + ->and(IntegrationDsn::parts('FIREFLY_TEST_DSN'))->toBe(['host' => 'db', 'port' => '1']); +}); + +it('builds a Laravel connection config per driver', function () { + putenv('FIREFLY_TEST_DSN=host=db;port=1433;dbname=firefly;user=sa;password=pw'); + + expect(IntegrationDsn::connectionConfigFor('pgsql', 'FIREFLY_TEST_DSN'))->toBe([ + 'driver' => 'pgsql', 'host' => 'db', 'port' => 1433, 'database' => 'firefly', 'username' => 'sa', + 'password' => 'pw', 'prefix' => '', 'charset' => 'utf8', 'search_path' => 'public', + ])->and(IntegrationDsn::connectionConfigFor('mariadb', 'FIREFLY_TEST_DSN')['charset'])->toBe('utf8mb4') + ->and(IntegrationDsn::connectionConfigFor('sqlsrv', 'FIREFLY_TEST_DSN'))->toMatchArray([ + 'driver' => 'sqlsrv', 'encrypt' => 'yes', 'trust_server_certificate' => 'false', + ])->and(IntegrationDsn::connectionConfigFor('mysql', 'FIREFLY_TEST_DSN', ['database' => 'other'])['database'])->toBe('other'); +}); + +it('defaults the port per driver when the DSN names none', function () { + putenv('FIREFLY_TEST_DSN=host=db;dbname=f;user=u;password=p'); + + expect(IntegrationDsn::connectionConfigFor('pgsql', 'FIREFLY_TEST_DSN')['port'])->toBe(5432) + ->and(IntegrationDsn::connectionConfigFor('mysql', 'FIREFLY_TEST_DSN')['port'])->toBe(3306) + ->and(IntegrationDsn::connectionConfigFor('sqlsrv', 'FIREFLY_TEST_DSN')['port'])->toBe(1433); +}); + +it('gives the sqlite lane a real file with WAL and a busy timeout, so two processes can share it', function () { + $path = IntegrationDsn::sqlitePath('dsn-test'); + integrationDsnRemoveSqlite($path); + + $config = IntegrationDsn::sqlite($path); + + expect(is_file($path))->toBeTrue() + ->and($config)->toMatchArray(['driver' => 'sqlite', 'database' => $path, 'journal_mode' => 'wal', 'busy_timeout' => 10000, 'foreign_key_constraints' => true]) + ->and(IntegrationDsn::skipReason('sqlite'))->toBeNull() + ->and(IntegrationDsn::connect('sqlite', 'dsn-test', ['database' => $path])->selectOne('select 1 as one'))->toEqual((object) ['one' => 1]); + + integrationDsnRemoveSqlite($path); +}); + +it('names the variable to set when a server lane cannot run', function () { + $reason = IntegrationDsn::skipReason('sqlsrv'); + + expect($reason)->toBeString() + ->and((string) $reason)->toMatch('/FIREFLY_SQLSRV_DSN|pdo_sqlsrv/') + ->and(IntegrationDsn::lanes())->toBe(['pgsql', 'mysql', 'mariadb', 'sqlsrv', 'sqlite']) + ->and(fn () => IntegrationDsn::skipReason('oracle'))->toThrow(InvalidArgumentException::class); +}); + +it('maps the RabbitMQ DSN in both forms to the five AMQP connection arguments', function () { + $previous = getenv(IntegrationDsn::RABBITMQ); + + try { + putenv(IntegrationDsn::RABBITMQ.'=host=127.0.0.1;port=55672;user=guest;password=pw'); + expect(IntegrationDsn::rabbitmq())->toBe(['host' => '127.0.0.1', 'port' => 55672, 'user' => 'guest', 'password' => 'pw', 'vhost' => '/']); + + putenv(IntegrationDsn::RABBITMQ.'=amqp://guest:pw@127.0.0.1:55672/%2f'); + expect(IntegrationDsn::rabbitmq())->toBe(['host' => '127.0.0.1', 'port' => 55672, 'user' => 'guest', 'password' => 'pw', 'vhost' => '/']); + } finally { + putenv($previous === false ? IntegrationDsn::RABBITMQ : IntegrationDsn::RABBITMQ.'='.$previous); + } +}); + +it('never echoes the DSN, which carries the password, in a parse error', function (string $dsn) { + $message = null; + try { + IntegrationDsn::parse($dsn); + } catch (InvalidArgumentException $error) { + $message = $error->getMessage(); + } + + expect($message)->toBeString()->not->toContain('S3cret-pw'); +})->with([ + 'segment without equals' => ['host=db;password=S3cret-pw;S3cret-pw'], + 'no host' => ['port=5432;password=S3cret-pw'], + 'url without host' => ['pgsql://u:S3cret-pw@/firefly'], +]); + +it('validates the SQL Server certificate unless the DSN opts out explicitly', function () { + putenv('FIREFLY_TEST_DSN=host=db;user=sa;password=pw'); + expect(IntegrationDsn::connectionConfigFor('sqlsrv', 'FIREFLY_TEST_DSN')['trust_server_certificate'])->toBe('false'); + + putenv('FIREFLY_TEST_DSN=host=db;user=sa;password=pw;trust_server_certificate=true'); + expect(IntegrationDsn::connectionConfigFor('sqlsrv', 'FIREFLY_TEST_DSN')['trust_server_certificate'])->toBe('true'); +}); + +it('lets a URL query string add extras but never override the authority', function () { + expect(IntegrationDsn::parse('pgsql://u:pw@db:5432/firefly?host=evil&port=1&user=root&password=x&search_path=app'))->toBe([ + 'scheme' => 'pgsql', 'host' => 'db', 'port' => '5432', 'user' => 'u', 'password' => 'pw', + 'dbname' => 'firefly', 'vhost' => 'firefly', 'search_path' => 'app', + ]); +}); + +it('keeps sqlite files in a private directory and refuses a planted symlink', function () { + $path = IntegrationDsn::sqlitePath('private-check'); + + expect(IntegrationDsn::sqlitePath('private-check'))->toBe($path) + ->and(dirname($path))->not->toBe(sys_get_temp_dir()) + ->and(fileperms(dirname($path)) & 0777)->toBe(0700); + + integrationDsnRemoveSqlite($path); + symlink(__FILE__, $path); + try { + expect(fn () => IntegrationDsn::sqlite($path))->toThrow(InvalidArgumentException::class, 'symbolic link'); + } finally { + unlink($path); + } + + IntegrationDsn::sqlite($path); + expect(fileperms($path) & 0777)->toBe(0600); + integrationDsnRemoveSqlite($path); +}); diff --git a/samples/lumen/composer.json b/samples/lumen/composer.json index 45514f68..96a036a0 100644 --- a/samples/lumen/composer.json +++ b/samples/lumen/composer.json @@ -38,6 +38,7 @@ "illuminate/support": "^13.0" }, "require-dev": { + "firefly/eda-database": "*@dev", "firefly/eda-postgres": "*@dev", "firefly/testing": "*@dev", "orchestra/testbench": "^11.1", diff --git a/samples/lumen/tests/Integration/OutboxPostgresIntegrationTestCase.php b/samples/lumen/tests/Integration/OutboxPostgresIntegrationTestCase.php index d2d8ee32..751ec256 100644 --- a/samples/lumen/tests/Integration/OutboxPostgresIntegrationTestCase.php +++ b/samples/lumen/tests/Integration/OutboxPostgresIntegrationTestCase.php @@ -4,8 +4,10 @@ namespace Lumen\Tests\Integration; +use Firefly\Eda\EdaConsumerServiceProvider; use Firefly\Eda\Postgres\EdaPostgresServiceProvider; use Firefly\Eda\Postgres\Outbox\OutboxSchema; +use Firefly\Testing\Integration\IntegrationDsn; use Illuminate\Contracts\Config\Repository; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Artisan; @@ -29,13 +31,18 @@ abstract class OutboxPostgresIntegrationTestCase extends LumenTestCase /** True only when a real Postgres target is reachable (the pdo_pgsql extension is loaded and FIREFLY_PG_DSN is set). */ public static function postgresAvailable(): bool { - return extension_loaded('pdo_pgsql') && getenv('FIREFLY_PG_DSN') !== false; + return IntegrationDsn::skipReason('pgsql') === null; } - /** @return list> */ + /** + * EdaConsumerServiceProvider is what registers `firefly:eda:consume` — the parent's list leaves it out because no + * unit test runs a consumer, and that is exactly how this test came to call a command that did not exist. + * + * @return list> + */ protected function fireflyProviders(): array { - return [...parent::fireflyProviders(), EdaPostgresServiceProvider::class]; + return [...parent::fireflyProviders(), EdaPostgresServiceProvider::class, EdaConsumerServiceProvider::class]; } /** @return array */ @@ -87,27 +94,14 @@ protected function migrate(): void } /** - * A Laravel pgsql connection config parsed from FIREFLY_PG_DSN (host=…;port=…;dbname=…;user=…;password=…) — - * mirrors packages/eda-postgres/tests/Integration/PostgresOutboxRoundTripTest::outbox_pg_config(). + * A Laravel pgsql connection config from FIREFLY_PG_DSN (host=…;port=…;dbname=…;user=…;password=…) — parsed by + * firefly/testing's IntegrationDsn. * * @return array */ private static function postgresConfig(): array { - $parts = []; - foreach (explode(';', (string) getenv('FIREFLY_PG_DSN')) as $kv) { - [$key, $value] = array_pad(explode('=', $kv, 2), 2, ''); - $parts[$key] = $value; - } - - return [ - 'driver' => 'pgsql', - 'host' => $parts['host'] ?? '127.0.0.1', - 'port' => $parts['port'] ?? '5432', - 'database' => $parts['dbname'] ?? 'postgres', - 'username' => $parts['user'] ?? 'postgres', - 'password' => $parts['password'] ?? '', - ]; + return IntegrationDsn::connectionConfig('pgsql'); } protected function tearDown(): void diff --git a/samples/lumen/tests/Integration/OutboxPostgresRoundTripTest.php b/samples/lumen/tests/Integration/OutboxPostgresRoundTripTest.php index 602c77f8..a4eaeb1f 100644 --- a/samples/lumen/tests/Integration/OutboxPostgresRoundTripTest.php +++ b/samples/lumen/tests/Integration/OutboxPostgresRoundTripTest.php @@ -7,6 +7,7 @@ use Firefly\Cqrs\Command\CommandBus; use Firefly\Cqrs\Query\QueryBus; use Firefly\Eda\Postgres\Outbox\OutboxSchema; +use Firefly\Testing\Integration\IntegrationDsn; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; use Lumen\Application\Command\Deposit; @@ -63,6 +64,6 @@ ->count())->toBe(1) ->and(DB::table(OutboxSchema::TABLE)->count())->toBe($total); // never re-inserted })->skip( - ! extension_loaded('pdo_pgsql') || getenv('FIREFLY_PG_DSN') === false, - 'Set FIREFLY_PG_DSN (and pdo_pgsql) to run the @group integration lumen Postgres outbox round-trip.', + ! OutboxPostgresIntegrationTestCase::postgresAvailable(), + (string) IntegrationDsn::skipReason('pgsql'), )->group('integration'); diff --git a/skeleton/config/firefly.php b/skeleton/config/firefly.php index d312c1c7..f686cf3d 100644 --- a/skeleton/config/firefly.php +++ b/skeleton/config/firefly.php @@ -2231,12 +2231,36 @@ | 'none' — no coordination (correct for a single instance). The default. | 'cache' — the app's atomic cache lock. | 'postgres' — Postgres advisory locks; requires firefly/scheduling-postgres. + | 'database' — a lease table on any database connection; requires firefly/scheduling-database. + | 'mysql' — MySQL/MariaDB GET_LOCK named locks; requires firefly/scheduling-database. | */ 'scheduling' => [ 'lock' => [ 'provider' => env('FIREFLY_SCHEDULING_LOCK', 'none'), + + /* + | provider=database — the lease table firefly_scheduling_locks (run `php artisan migrate`) on + | `connection` (UNSET, null or blank = the default connection). The TTL is honoured on every + | driver: a hung task blocks its schedule for at most its #[Scheduled(lockTtl:)]. + | + | Default: connection unset. + */ + // 'database' => [ + // 'connection' => 'mysql', + // ], + + /* + | provider=mysql — GET_LOCK named locks, held for the connection's session and released when it + | closes; no table, no TTL (lock-ttl is ignored). `connection` must be a mysql or mariadb + | connection (UNSET, null or blank = the default connection). + | + | Default: connection unset. + */ + // 'mysql' => [ + // 'connection' => 'mysql', + // ], ], /* @@ -2327,14 +2351,15 @@ /* |-------------------------------------------------------------------------- - | Events — firefly/eda (+ eda-rabbitmq / eda-postgres / eda-kafka) + | Events — firefly/eda (+ eda-database / eda-rabbitmq / eda-postgres / eda-kafka) |-------------------------------------------------------------------------- | | `provider` selects the EventPublisher adapter: | 'memory' — in-process bus. The default; listeners run synchronously. | 'queue' — Laravel queue; listeners run in `queue:work`. + | 'database' — requires firefly/eda-database; the same-transaction outbox on any database. | 'rabbitmq' — requires firefly/eda-rabbitmq. - | 'postgres' — requires firefly/eda-postgres; enables the same-transaction outbox. + | 'postgres' — requires firefly/eda-postgres; the database outbox plus pg_notify/LISTEN. | 'kafka' — requires firefly/eda-kafka and ext-rdkafka. | | The broker providers are consumed by `php artisan firefly:eda:consume`; 'queue' uses @@ -2363,6 +2388,19 @@ 'retries' => 0, 'retry_delay' => 0.0, + /* + | Where a listener's envelope goes once its retries are exhausted, under EVERY provider. 'memory' + | keeps it in the process (lost when it exits); 'database' writes it to firefly_eda_dead_letters + | on `connection` (UNSET = the default connection) — run `php artisan migrate` — and + | `php artisan firefly:eda:dead-letters:replay` republishes it. + | + | Defaults: store 'memory', connection unset. + */ + 'dead-letter' => [ + 'store' => env('FIREFLY_EDA_DEAD_LETTER_STORE', 'memory'), + // 'connection' => 'mysql', + ], + /* | Broker destinations `firefly:eda:consume` binds when `--destination` is not passed. Must be a | list of strings. @@ -2413,9 +2451,38 @@ // ], /* - | provider=postgres — the same-transaction outbox. `connection` UNSET means the default database - | connection. `channel` is the LISTEN/NOTIFY channel; `max_attempts` bounds relay retries before - | a row is marked FAILED. + | provider=database — and provider=postgres, which reads the same block — the same-transaction + | outbox (firefly/eda-database). `connection` UNSET means the default database connection. + | `claim-strategy` decides how a consumer or relay takes rows: 'auto' asks the server (SKIP LOCKED + | on PostgreSQL, MySQL 8.0.1+ and MariaDB 10.6+; READPAST on SQL Server; a conditional-UPDATE lease + | everywhere else), or force 'skip-locked' | 'read-past' | 'optimistic-lease'. A claimed row + | belongs to its claimant for `lease-seconds` — size it above your slowest listener. An idle consumer + | sleeps `poll-interval-ms` between claims (provider=postgres waits on NOTIFY instead). + | `relay.downstream-provider` is only read by `php artisan firefly:outbox:relay`. + | + | Every key falls back to its firefly.eda.postgres.* spelling below when it is unset or null (an + | unset env() counts as unset). + | + | Defaults: connection unset, channel 'firefly_eda_events', max-attempts 3, claim-strategy 'auto', + | lease-seconds 300, poll-interval-ms 500, relay.downstream-provider unset. + */ + // 'database' => [ + // 'connection' => 'mysql', + // 'channel' => 'firefly_eda_events', + // 'max-attempts' => 3, + // 'claim-strategy' => env('FIREFLY_EDA_CLAIM_STRATEGY', 'auto'), + // 'lease-seconds' => 300, + // 'poll-interval-ms' => 500, + // 'relay' => [ + // 'downstream-provider' => 'rabbitmq', + // ], + // ], + + /* + | provider=postgres — the legacy spelling of the block above (each key here is an alias of its + | firefly.eda.database.* twin); `channel` is also the LISTEN/NOTIFY channel. `connection` UNSET + | means the default database connection; `max_attempts` bounds relay retries before a row is + | marked FAILED. | | `relay.downstream_provider` is OPTIONAL and only used by `php artisan firefly:outbox:relay`, | which forwards committed outbox rows to a SECOND broker. It takes 'rabbitmq', 'kafka', an @@ -2444,13 +2511,13 @@ // ], /* - | Broker publishers and the trace. The rabbitmq, kafka and postgres adapters route publish() + | Broker publishers and the trace. The rabbitmq, kafka, database and postgres adapters route publish() | through the same EdaTracing seam the in-memory and queue buses use, so the envelope that reaches | the wire carries a `traceparent` (and `tracestate`) and the consumer on the far side — ours or a | third party's — continues the trace instead of starting a new one. The consume side was already - | traced; this is the other half. On Postgres it covers BOTH writers of an outbox row: the - | EventPublisher bean and the in-transaction OutboxPreCommitHook, which is the only path a domain - | event takes under provider=postgres — so the traced row is the one that commits with the + | traced; this is the other half. On the outbox (provider=database or postgres) it covers BOTH writers + | of a row: the EventPublisher bean and the in-transaction OutboxPreCommitHook, which is the only path + | a domain event takes under those providers — so the traced row is the one that commits with the | aggregate. `firefly:outbox:relay` forwards a claimed row under that row's own trace rather than | starting a new one. | @@ -2458,7 +2525,7 @@ | `firefly.observability.tracing.eda.enabled` are on, because with them off the bound EdaTracing is | the no-op. Set this to false to keep in-process spans while refusing to put trace identifiers on | a wire someone else reads: it is read in ONE place (Firefly\Eda\Tracing\BrokerTracing), by the - | three publisher beans AND by the relay, so a shipped adapter cannot slip past it — the relay + | four publisher beans AND by the relay, so a shipped adapter cannot slip past it — the relay | applies it whether you name the adapter by its alias (`rabbitmq`) or by its own class-string. | A downstream of your OWN, bound under `firefly.eda.relay.downstream` or named by a publisher | class this framework ships no adapter for, is yours to construct and therefore yours to gate: @@ -2487,6 +2554,20 @@ 'messaging' => [ 'provider' => env('FIREFLY_MESSAGING_PROVIDER', 'memory'), + /* + | Where a #[MessageListener(deadLetterTopic: …)] message goes once its retries are exhausted. + | 'memory' keeps it in the process (lost when it exits); 'database' writes it to + | firefly_messaging_dead_letters on `connection` (UNSET = the default connection), the raw value + | base64-encoded — run `php artisan migrate` — and + | `php artisan firefly:messaging:dead-letters:replay` republishes it to its original topic. + | + | Defaults: store 'memory', connection unset. + */ + 'dead-letter' => [ + 'store' => env('FIREFLY_MESSAGING_DEAD_LETTER_STORE', 'memory'), + // 'connection' => 'mysql', + ], + // 'queue' => [ // 'connection' => 'redis', // 'name' => 'messages', diff --git a/tests/DocsDiagramsTest.php b/tests/DocsDiagramsTest.php index abe327a5..36c4192a 100644 --- a/tests/DocsDiagramsTest.php +++ b/tests/DocsDiagramsTest.php @@ -19,7 +19,9 @@ use Firefly\Eda\Bus\InMemoryEventBus; use Firefly\Eda\Bus\QueueEventBus; use Firefly\Eda\Consumer\SubscriberRegistrySink; +use Firefly\Eda\Database\DatabaseOutboxAutoConfiguration; use Firefly\Eda\EventEnvelope; +use Firefly\Eda\Postgres\PostgresOutboxAutoConfiguration; use Firefly\Eda\Tracing\EdaTracing; use Firefly\Observability\Cqrs\TracerCqrsTracing; use Firefly\Observability\Eda\TracerEdaTracing; @@ -1495,3 +1497,55 @@ } } }); + +it('scopes the CQRS/EDA bridge figure\'s atomicity caption to every provider that registers the same-tx outbox', function () { + // The bridge figure ends on a caption that sends the reader elsewhere for same-transaction atomicity. It was + // written when only firefly/eda-postgres wrote the outbox row inside the aggregate's transaction, and said + // so: "true same-tx atomicity is the postgres outbox adapter". firefly/eda-database now registers the same + // pre-commit hook for provider=database on every supported database, and provider=postgres is that outbox + // plus pg_notify/LISTEN — so the caption named one of two providers and implied the other did not exist. + // outbox-flow.svg was corrected in the same change and this figure was not. The providers are DERIVED here + // from the #[ConditionalOnProperty] on each auto-configuration's OutboxPreCommitHook bean, so a third outbox + // provider fails this test until the caption and the adapter row name it too. + $root = dirname(__DIR__); + $docs = $root.'/docs/assets/diagrams/cqrs-eda-bridge.svg'; + $book = $root.'/book/art/figures/cqrs-eda-bridge.svg'; + + expect(file_get_contents($book))->toBe(file_get_contents($docs), 'book/art/figures/cqrs-eda-bridge.svg must stay byte-identical to the docs copy'); + + $providers = []; + foreach ([DatabaseOutboxAutoConfiguration::class, PostgresOutboxAutoConfiguration::class] as $configuration) { + foreach ((new ReflectionMethod($configuration, 'outboxPreCommitHook'))->getAttributes(ConditionalOnProperty::class) as $attribute) { + $condition = $attribute->newInstance(); + if ($condition->name === 'firefly.eda.provider' && $condition->havingValue !== null) { + $providers[] = $condition->havingValue; + } + } + } + sort($providers); + expect($providers)->toBe(['database', 'postgres']); + + $svg = simplexml_load_file($docs); + if ($svg === false) { + throw new RuntimeException('docs/assets/diagrams/cqrs-eda-bridge.svg is not well-formed XML'); + } + $svg->registerXPathNamespace('s', 'http://www.w3.org/2000/svg'); + $texts = array_map(static fn (SimpleXMLElement $node): string => trim((string) $node), $svg->xpath('//s:text') ?: []); + + $captions = array_values(array_filter($texts, static fn (string $text): bool => str_contains($text, 'same-tx atomicity'))); + expect($captions)->toHaveCount(1, 'the bridge figure must still say where same-transaction atomicity lives'); + [$caption] = $captions; + + expect(str_contains($caption, 'postgres outbox adapter'))->toBeFalse('the caption still credits only the postgres adapter with the same-tx outbox') + ->and(str_contains($caption, 'outbox-flow.svg'))->toBeTrue('the caption must keep pointing at the outbox figure'); + foreach ($providers as $provider) { + expect(preg_match('/\b'.preg_quote($provider, '/').'\b/', $caption))->toBe(1, "the caption does not name provider '{$provider}', which registers the same-tx outbox"); + } + + // The adapter row under the EventPublisher port draws one box per provider; each outbox provider must be + // drawn, and the "(same-tx outbox)" note must sit under the outbox providers, not only under postgres. + foreach ($providers as $provider) { + expect(in_array($provider, $texts, true))->toBeTrue("the adapter row draws no box for provider '{$provider}'"); + } + expect(count(array_keys($texts, '(same-tx outbox)', true)))->toBe(count($providers), 'every outbox provider box carries the (same-tx outbox) note'); +}); diff --git a/tests/Integration/IntegrationLaneTest.php b/tests/Integration/IntegrationLaneTest.php new file mode 100644 index 00000000..d73e47a5 --- /dev/null +++ b/tests/Integration/IntegrationLaneTest.php @@ -0,0 +1,45 @@ +toBeFalse("the brokers lane runs without {$variable}"); + } + expect(extension_loaded('rdkafka'))->toBeTrue('the brokers lane runs without ext-rdkafka'); + + $rabbit = IntegrationDsn::rabbitmq(); + $kafka = explode(':', (string) IntegrationDsn::value(IntegrationDsn::KAFKA)); + $redis = parse_url((string) IntegrationDsn::value(IntegrationDsn::REDIS)); + $endpoints = [ + 'rabbitmq' => [$rabbit['host'], $rabbit['port']], + 'kafka' => [$kafka[0], (int) ($kafka[1] ?? 9092)], + 'redis' => [is_array($redis) ? (string) ($redis['host'] ?? '127.0.0.1') : '127.0.0.1', is_array($redis) ? (int) ($redis['port'] ?? 6379) : 6379], + ]; + foreach ($endpoints as $name => [$host, $port]) { + $socket = @fsockopen($host, $port, $errno, $error, 5.0); + expect($socket)->not->toBeFalse("{$name} at {$host}:{$port} is unreachable: {$error}"); + if (is_resource($socket)) { + fclose($socket); + } + } + + return; + } + + expect(IntegrationDsn::DATABASES)->toHaveKey($lane); + expect(IntegrationDsn::skipReason($lane))->toBeNull(); + expect(IntegrationDsn::connect($lane, 'lane-sanity')->selectOne('select 1 as one'))->not->toBeNull(); +})->skip(IntegrationDsn::missing(IntegrationDsn::LANE), 'FIREFLY_INTEGRATION_LANE is only set by the CI integration jobs.')->group('integration'); diff --git a/tests/IntegrationWorkflowTest.php b/tests/IntegrationWorkflowTest.php new file mode 100644 index 00000000..45077f91 --- /dev/null +++ b/tests/IntegrationWorkflowTest.php @@ -0,0 +1,154 @@ + + */ +function integrationYamlMap(mixed $value, string $what): array +{ + if (! is_array($value) || array_is_list($value) && $value !== []) { + throw new RuntimeException("{$what} is not a YAML mapping."); + } + + /** @var array $value */ + return $value; +} + +/** @return list */ +function integrationYamlList(mixed $value, string $what): array +{ + if (! is_array($value) || ! array_is_list($value)) { + throw new RuntimeException("{$what} is not a YAML sequence."); + } + + return $value; +} + +/** @return array */ +function integrationCiJobs(): array +{ + $ci = integrationYamlMap(Yaml::parseFile(dirname(__DIR__).'/.github/workflows/ci.yml'), 'ci.yml'); + + return integrationYamlMap($ci['jobs'] ?? null, 'ci.yml jobs'); +} + +function integrationYamlString(mixed $value): string +{ + return is_scalar($value) ? (string) $value : ''; +} + +/** + * The `run:` text of every step of a job, joined. + * + * @param array $job + */ +function integrationRunText(array $job): string +{ + $runs = ''; + foreach (integrationYamlList($job['steps'] ?? null, 'steps') as $step) { + $step = integrationYamlMap($step, 'step'); + $runs .= is_string($step['run'] ?? null) ? $step['run']."\n" : ''; + } + + return $runs; +} + +it('runs the integration group through one composer script with enough memory to collect it', function () { + /** @var array{scripts: array>} $composer */ + $composer = json_decode((string) file_get_contents(dirname(__DIR__).'/composer.json'), true, flags: JSON_THROW_ON_ERROR); + + expect($composer['scripts']['test:integration'] ?? null)->toBe('@php -d memory_limit=1G vendor/bin/pest --group integration'); +}); + +it('runs one integration lane per database server, each with a healthy service and only its own DSN', function () { + $job = integrationYamlMap(integrationCiJobs()['integration'] ?? null, 'jobs.integration'); + $matrix = integrationYamlMap(integrationYamlMap($job['strategy'] ?? null, 'strategy')['matrix'] ?? null, 'matrix'); + + $lanes = []; + foreach (integrationYamlList($matrix['include'] ?? null, 'matrix.include') as $entry) { + $entry = integrationYamlMap($entry, 'matrix entry'); + $lanes[integrationYamlString($entry['lane'] ?? null)] = [integrationYamlString($entry['image'] ?? null), integrationYamlString($entry['dsn-variable'] ?? null)]; + expect(integrationYamlString($entry['health'] ?? null))->not->toBe(''); + } + + expect($lanes)->toBe([ + 'pgsql' => ['postgres:16', 'FIREFLY_PG_DSN'], + 'mysql' => ['mysql:8.4', 'FIREFLY_MYSQL_DSN'], + 'mariadb' => ['mariadb:11', 'FIREFLY_MARIADB_DSN'], + 'sqlsrv' => ['mcr.microsoft.com/mssql/server:2022-latest', 'FIREFLY_SQLSRV_DSN'], + ]); + + $service = integrationYamlMap(integrationYamlMap($job['services'] ?? null, 'services')['database'] ?? null, 'services.database'); + expect($service['image'] ?? null)->toBe('${{ matrix.image }}') + ->and(integrationYamlString($service['options'] ?? null))->toContain('--health-cmd'); + + $run = integrationRunText($job); + expect($run)->toContain("export \${{ matrix.dsn-variable }}='\${{ matrix.dsn }}'") + ->and($run)->toContain('composer test:integration') + ->and(substr_count($run, 'export '))->toBe(1) + ->and(json_encode($job, JSON_THROW_ON_ERROR))->not->toContain('FIREFLY_RABBITMQ_DSN'); +}); + +it('runs the brokers lane against RabbitMQ, Kafka (with ext-rdkafka) and Redis, each health-checked', function () { + $job = integrationYamlMap(integrationCiJobs()['brokers'] ?? null, 'jobs.brokers'); + $services = integrationYamlMap($job['services'] ?? null, 'services'); + + expect(array_keys($services))->toBe(['rabbitmq', 'kafka', 'redis']); + foreach ($services as $name => $service) { + expect(integrationYamlString(integrationYamlMap($service, $name)['options'] ?? null))->toContain('--health-cmd'); + } + + $env = []; + foreach (integrationYamlList($job['steps'] ?? null, 'steps') as $step) { + $step = integrationYamlMap($step, 'step'); + if (str_contains(integrationYamlString($step['run'] ?? null), 'composer test:integration')) { + $env = integrationYamlMap($step['env'] ?? null, 'env'); + } + } + + expect(array_keys($env))->toBe(['FIREFLY_INTEGRATION_LANE', 'FIREFLY_RABBITMQ_DSN', 'FIREFLY_KAFKA_BROKERS', 'FIREFLY_REDIS_URL']) + ->and(json_encode($job, JSON_THROW_ON_ERROR))->toContain('rdkafka'); +}); + +it('mirrors every CI image in docker-compose.integration.yml', function () { + $compose = integrationYamlMap(Yaml::parseFile(dirname(__DIR__).'/docker-compose.integration.yml'), 'compose'); + $images = []; + foreach (integrationYamlMap($compose['services'] ?? null, 'services') as $name => $service) { + $images[$name] = integrationYamlString(integrationYamlMap($service, $name)['image'] ?? null); + } + + $jobs = integrationCiJobs(); + $strategy = integrationYamlMap(integrationYamlMap($jobs['integration'] ?? null, 'integration')['strategy'] ?? null, 'strategy'); + $ci = []; + foreach (integrationYamlList(integrationYamlMap($strategy['matrix'] ?? null, 'matrix')['include'] ?? null, 'include') as $entry) { + $ci[] = integrationYamlString(integrationYamlMap($entry, 'entry')['image'] ?? null); + } + foreach (integrationYamlMap(integrationYamlMap($jobs['brokers'] ?? null, 'brokers')['services'] ?? null, 'services') as $name => $service) { + $ci[] = integrationYamlString(integrationYamlMap($service, $name)['image'] ?? null); + } + + sort($ci); + $local = array_values($images); + sort($local); + + expect($local)->toBe($ci); +}); + +it('keeps the compose mirror out of the library archive', function () { + // The compose file is a contributor tool like phpunit.xml.dist and testbench.yaml; a consumer installing + // fireflyframework/larafly must not find a set of database containers in its vendor directory. + $attributes = (string) file_get_contents(dirname(__DIR__).'/.gitattributes'); + + expect($attributes)->toMatch('#^/docker-compose\.integration\.yml\s+export-ignore$#m'); +});