feat(tempest): add ecotone/tempest framework integration package#673
Open
dgafka wants to merge 23 commits into
Open
feat(tempest): add ecotone/tempest framework integration package#673dgafka wants to merge 23 commits into
dgafka wants to merge 23 commits into
Conversation
Scaffolds the packages/Tempest package and wires Ecotone into the Tempest PHP framework via Tempest's Discovery and Initializer system so that CommandBus/QueryBus/EventBus and all Ecotone gateways become resolvable from the Tempest container with no manual registration. Stage 1 components: - EcotoneConfig: Tempest config object (ecotone.config.php) mirroring config/ecotone.php knobs; defaults from env() - TempestConfigurationVariableService: ConfigurationVariableService over Tempest env() - MessagingSystemInitializer: Tempest Singleton Initializer that compiles Ecotone's DefinitionHolder via LazyInMemoryContainer + TempestPsrContainerAdapter as the external container for user services - EcotoneServiceInitializer: DynamicInitializer for CommandBus/QueryBus/EventBus and all compiled service IDs; triggers MessagingSystemInitializer on first resolution - TempestPsrContainerAdapter: PSR-11 wrapper over Tempest's container - EcotoneIntegrationTest: custom test base that boots FrameworkKernel with explicit discovery locations, bypassing problematic monorepo paths - Root composer.json: adds Ecotone\Tempest\ autoload, Test\Ecotone\Tempest\ autoload-dev, ecotone/tempest in replace map, tempest/framework in require-dev, phpstan updated to ^1.8|^2.0 for Tempest compatibility Stage 1 tests (all green): - TempestApplicationTest: CommandBus resolves, command→handler flow, expression language - TempestConfigurationVariableServiceTest: env reads/has Stages 2–4 (DBAL, aggregate repository, multi-tenant) are NOT started.
… core gateways EcotoneServiceInitializer::canInitialize() previously matched only the three core gateways (CommandBus/QueryBus/EventBus) until the messaging system was first built. A custom business interface (#[BusinessMethod] gateway interface) resolved as the very first Ecotone touch from Tempest's container was missed and caused DependencyCouldNotBeInstantiated. Fix: when $compiledServiceIds is null and EcotoneConfig is already registered in the container (i.e., after test setup / app boot), eagerly trigger MessagingSystemInitializer via GenericContainer::instance()->get(ConfiguredMessagingSystem) using a $compiling guard to prevent re-entrancy. Once compiled, the full set of service IDs is used to answer canInitialize() order-independently. Uses GenericContainer::instance() (static accessor) instead of injecting Container in the constructor to avoid the circular-dependency chain that Tempest detects when DynamicInitializer is autowired during boot.
Implements spec item 7 (Stage 1, Gap 2): Ecotone registered commands are now exposed to Tempest's console system so `./tempest` can list and run them. ## Components **ConsoleCommandProxyGenerator** — takes ConsoleCommandConfiguration[] and writes one PHP proxy class per command to a directory. Each proxy carries #[ConsoleCommand(name: '<ecotone name>', allowDynamicArguments: true)] on its __invoke() method, resolves ConfiguredMessagingSystem and ConsoleArgumentBag via #[Inject], and forwards all console arguments to ConsoleCommandRunner::execute(). **EcotoneConsoleCommandDiscovery implements Discovery** — discovered automatically by Tempest's DiscoveryDiscovery since it is on the Ecotone\Tempest\ discovery location. Its apply() hook: (a) guards on EcotoneConfig being present (skips early if kernel not yet configured); (b) triggers MessagingSystemInitializer directly to compile the definition holder (avoids container initializer ordering issue where InitializerDiscovery may not have run yet); (c) generates proxy files to sys_get_temp_dir()/ecotone_tempest_console_proxies/; (d) requires them; (e) registers each proxy's __invoke method with ConsoleConfig::addCommand() via MethodReflector, making the commands available to Tempest's console application. **MessagingSystemInitializer** — now stores the ContainerDefinitionsHolder in a static property (getDefinitionHolder/clearDefinitionHolder) so EcotoneConsoleCommandDiscovery can access the registered command configs after compilation. ## Lifecycle hook decision Tempest's Discovery.apply() hook is chosen over KernelEvent::BOOTED because: - apply() runs during bootDiscovery() BEFORE the BOOTED event fires - Commands need to be in ConsoleConfig before the console application starts - The Discovery interface is auto-discovered with no additional dependencies - BOOTED would be too late if ConsoleApplication queries commands before BOOTED Limitation: proxy files live in sys_get_temp_dir() across requests; they are regenerated each boot (no content hash check). A future improvement could cache using the Ecotone config hash, but the current approach is correct and tested. ## EcotoneIntegrationTest change EcotoneConfig is now registered in the container BEFORE bootDiscovery() (moved from after the full kernel boot) so that EcotoneConsoleCommandDiscovery::apply() can check container->has(EcotoneConfig::class) and trigger compilation.
ConsoleCommandProxyGenerator.generate() now accepts an optional configHash; when provided it writes a .ecotone_hash marker file in the output directory and skips all file writing on subsequent calls when the hash is unchanged. MessagingSystemInitializer stores and exposes the computed cacheHash (from getCacheMessagingFileNameBasedOnConfig) via getConfigHash(), and EcotoneConsoleCommandDiscovery passes that hash to the generator so proxy files are only rewritten when the Ecotone config changes. ConsoleConfig registration still happens on every boot.
Add a behavioral test asserting that the Ecotone-generated console proxy class is resolvable from the Tempest container and its __invoke() returns ExitCode::SUCCESS by forwarding execution through the real ConsoleCommandRunner gateway. Complements the registration test with end-to-end execution coverage for the proxy forwarding path.
Add a test proving that two sequential kernel boots in a single PHP process produce independent messaging systems: after clearing static state (EcotoneServiceInitializer::clearCache, MessagingSystemInitializer::clearDefinitionHolder) and calling setupKernel() again, the second boot builds a fresh LazyInMemoryContainer and sees no state from the first boot's command handlers. Assessment (Item 3): the static fields in MessagingSystemInitializer ($definitionHolder, $configHash) and EcotoneServiceInitializer ($compiledServiceIds) are per-process memoizations that are correct for production (one kernel per process). Test isolation is fully covered by the clearCache/clearDefinitionHolder calls in EcotoneIntegrationTest tearDown. No correctness bug exists, so no structural change is made. The only optimization gap noted: when the production cache is warm, $configHash is set to null, so proxy files are rewritten on every process start rather than being gated by hash — this is safe but suboptimal; addressing it is out of scope for Stage 1 hardening.
…d ecotone:run - Update ConsoleCommandProxyGenerator to inject Console and print ConsoleCommandResultSet as column-separated lines via writeln, mirroring the Symfony/Laravel integration behaviour - Add proxy-cache teardown to EcotoneIntegrationTest so each test process starts with a fresh generated-proxy directory - Add test_ecotone_list_command_runs_through_tempest_console_runner_by_name_and_prints_column_headers and test_generator_produces_proxy_code_that_prints_console_command_result_set to ConsoleCommandProxyTest - Add ConsoleCommandEndToEndTest with async-queue fixture (AsyncQueueChannelConfiguration + NotifyCommandHandler) to cover ecotone:list showing the registered consumer name and ecotone:run running that consumer to completion via finishWhenNoMessages
Adds TempestConnectionReference, TempestConnectionResolver, and TempestConnectionModule so Ecotone DBAL features can run off Tempest's DatabaseConfig. Ports the LaravelConnectionReference/LaravelConnectionResolver/PDO-driver pattern onto Tempest: builds a PDO from the Tempest DatabaseConfig (dsn/username/password/options), wraps it in a per-dialect Doctrine DBAL Driver+Connection, and registers an AlreadyConnectedDbalConnectionFactory under the configured reference name. Registers TEMPEST_PACKAGE in ModulePackageList/ModuleClassList so the module is discovered via the same class-filtered package mechanism as the Laravel/Symfony modules. Three Stage 2 tests (WithConnection positive, WithoutConnection negative, Connectivity live-query against docker postgres) all pass alongside the 17 Stage 1 tests.
… Stage 3 Implements the aggregate repository contract for Tempest active-record models (IsDatabaseModel), mirroring the Laravel EloquentRepository pattern. Wires TempestRepositoryBuilder into MessagingSystemInitializer so any class using IsDatabaseModel is automatically handled as an Ecotone aggregate repository.
…stgres schema setup Adds TempestRepositoryIntegrationTest that places an order through the command bus (creating and persisting a Tempest IsDatabaseModel aggregate), asserts it is findable via findById, then round-trips a cancel+query cycle through the messaging system. Includes the Order fixture class and PlaceOrder command. Schema is created/dropped around each test using Tempest's Database interface.
…tiTenantConnectionFactory - Extend TempestConnectionModule to wire MultiTenantConfiguration: registers per-tenant TempestConnectionReference connection factories and the TempestTenantDatabaseSwitcher service with handlers on the Ecotone activate/ deactivate tenant channels. Guard on class_exists(HeaderBasedMultiTenantConnectionFactory). canHandle() now also accepts MultiTenantConfiguration. - TempestTenantDatabaseSwitcher: on switchOn, re-registers the tenant DatabaseConfig as the Tempest default and invalidates the Connection/Database singletons so the next resolution rebuilds from the tenant config. switchOff restores the original default. Uses GenericContainer::instance() to avoid circular autowiring. - TempestConnectionReference: carries an optional DatabaseConfig (for multi-tenant per-tenant configs). getDefinition() serialises the config via base64+serialize so it survives Ecotone definition caching. Single-connection path unchanged (falls back to container-resolved DatabaseConfig::class). Adds clearRegistry() stub for test compat. - TempestConnectionResolver: takes only TempestConnectionReference (one arg). Reads DatabaseConfig from the reference (inline or via GenericContainer::instance() fallback). - Test fixtures: RegisterCustomer, Customer, CustomerInterface (#[DbalWrite]), CustomerRepository (#[DbalQuery] FIRST_COLUMN), CustomerService (command + query handlers with #[Reference] injection), MultiTenantEcotoneConfiguration (#[ServiceContext] returning MultiTenantConfiguration mapping tenant_a→postgres and tenant_b→mysql). - MultiTenantTest: two green tests — test_run_message_handlers_for_multi_tenant_connection (ORM-side DBAL isolation via CustomerInterface) and test_using_dbal_based_business_interfaces (DBAL business interface write path). Skipped (queues excluded from scope): test_sending_events_using_laravel_db_queue, test_transactions_rollbacks_model_changes_and_published_events, test_optimize_clear_triggers_ecotone_cache_clear_via_event. All 26 tests green (24 prior + 2 new Stage 4).
…es from Tempest Composer PSR-4 roots When EcotoneConfig::loadAppNamespaces=true (default) and no explicit namespaces are set, MessagingSystemInitializer now resolves the Tempest Composer singleton from the container and uses its app PSR-4 namespace prefixes for Ecotone handler discovery. This means a fresh Tempest app with an App\ root and no ecotone.config.php discovers its handlers automatically. Adds AppNamespaceAutoDiscoveryTest proving zero-config discovery works. Registers App\Tempest\ in the monorepo autoload-dev for the test fixture.
…nd set 'logger' key LoggerInterface is provided by Tempest's DynamicInitializer so container->has() always returns false; use get() in a try/catch instead. Also set the logger under both 'logger' (the key Ecotone's LoggingService resolves at runtime) and LoggerInterface::class so log messages from command/query/event handlers flow through the Tempest logger. Adds LoggerWiringTest proving Ecotone query handler logs appear in the configured Tempest log channel.
…oots skip proxy regeneration On cold boot the computed config hash is now written to messaging_system_hash alongside messaging_system. On the warm-cache fast-path the persisted hash is read back so getConfigHash() returns a stable non-null value, enabling ConsoleCommandProxyGenerator to correctly skip proxy file rewrites. Also moves console proxy output directory under the Ecotone cache dir (ecotone_tempest/console_proxies) instead of sys_get_temp_dir() root. Adds ProdCacheHashTest proving warm-cache boot returns the same hash as the cold boot.
…tone cache directory Mirrors Laravel's optimize:clear and Symfony's CacheClearer. The command removes the full ecotone_tempest directory including the compiled messaging_system cache and generated console proxy files. Adds CacheClearCommandTest covering both the messaging-system cache file and the console proxy directory removal.
…ation from app PSR-4 Item A: boots FrameworkKernel with tests/app as root so loadComposer() loads tests/app/composer.json (App\Tempest\ => src/), exercises the deriveNamespacesFromComposer path in MessagingSystemInitializer without explicit EcotoneConfig::namespaces. Handler resolved via derived namespace. Monorepo-only shim: injectDiscoveryConfig uses AutoloadDiscoveryLocations from /data/app root (not tests/app) to get vendor package discovery locations — a real install would have a full vendor/ in the app root.
…cence key, and prod-cache Item D: parameter() function with hardcoded and env-variable-backed values via TempestConfigurationVariableService; licence key boot test; warm-cache handler execution parity test (Laravel/Symfony-mirrored coverage). Error-channel test skipped: requires async queue infrastructure and Enterprise licence key — not feasible synchronously without the queue transport. Logger-during-execution already covered by LoggerWiringTest (committed earlier).
…ence credential guard tests Item C1: add suggest block to composer.json describing ecotone/dbal and enqueue/dbal as optional deps enabling DBAL/multi-tenant features. Item C2: add ConnectionReferenceCredentialsTest as a guard capturing the current credential serialization behavior. Decision: kept existing base64(serialize(DatabaseConfig)) approach since the prior attempt to change it broke per-tenant connection routing. Credentials are written to the on-disk cache when cacheConfiguration=true; documented in README with a security note. MultiTenantTest stays green (verified).
…tempest docs Item B: covers composer require, zero-config handler auto-discovery from PSR-4 roots, EcotoneConfig reference table, console commands, DBAL single/multi-tenant setup via #[ServiceContext], production caching, expression language parameter() function, and a security note about DatabaseConfig credentials in the on-disk cache.
…o-config canInitialize) EcotoneServiceInitializer::canInitialize() was guarded by has(EcotoneConfig::class), so in a zero-config app (no ecotone.config.php discovered) every gateway get() failed with "not an instantiable class". MessagingSystemInitializer already has a correct fallback (resolveEcotoneConfig returns new EcotoneConfig()), so the guard is wrong. Remove the has(EcotoneConfig) guard from EcotoneServiceInitializer so compile is triggered unconditionally on first gateway request. Keep the guard in EcotoneConsoleCommandDiscovery::apply() (which runs at discovery time, before handlers are scanned) so it does not eagerly compile all modules during boot when no config exists. Add a real-boot test: CommandBus resolves and handles a command without any prior ConfiguredMessagingSystem touch, proving EcotoneServiceInitializer triggers the compile.
…oll back Tempest model writes TempestConnectionResolver was creating a new PDO connection independent of Tempest's own singleton PDOConnection. This meant Ecotone's DbalTransactionInterceptor opened a transaction on one connection while IsDatabaseModel::save() wrote on another — so rollback on exception did not undo model inserts. Fix: when no per-tenant DatabaseConfig is serialized in the reference (the default single-connection path), resolve Tempest's singleton Connection from the container, extract its PDO via reflection, and pass it to Doctrine's Connection. Ecotone and Tempest's ORM now share one PDO resource, so transactions wrap both. Add SharedConnectionTransactionTest: command handler inserts via IsDatabaseModel, throws RuntimeException, asserts the row count is 0 (rollback succeeded).
…onfig (no serialized credentials)
TempestConnectionReference::create('tag') now stores only the tag name. At runtime
TempestConnectionResolver resolves the DatabaseConfig from Tempest's container by tag
(no credentials serialized into Ecotone's compiled cache). Users register configs in
standard Tempest *.config.php files:
return new PostgresConfig(host: '...', tag: 'tenant_a');
// In #[ServiceContext]:
TempestConnectionReference::create('tenant_a')
Per-tenant Connection singletons are registered in Tempest's container on first use
so they are shared across Ecotone and Tempest ORM within the same message.
TempestConnectionReference::defaultConnection() uses TempestDynamicDriver /
TempestDynamicDriverConnection — a Doctrine Driver that re-resolves Tempest's default
Connection singleton on every DBAL call. Combined with TempestTenantDatabaseSwitcher
closing the Doctrine Connection on tenant switch, the DbalTransactionInterceptor follows
tenant connection promotions transparently.
TempestTenantDatabaseSwitcher::switchOn now resolves the tagged DatabaseConfig from the
container (instead of the previously embedded/deserialized config) and ensures the tagged
Connection singleton exists before promoting it as the default.
ConnectionReferenceCredentialsTest updated to reflect the new tag-based API.
MultiTenantTest::setUp now registers tagged DatabaseConfig objects in the container.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why is this change proposed?
Tempest is a modern PHP framework (v3.x, PHP 8.4+) gaining adoption, and Ecotone had no first-class integration for it. Developers using Tempest had no way to use CQRS, Event Sourcing, Sagas, or messaging patterns without hand-wiring everything from scratch. This PR adds
ecotone/tempest— a full framework integration that makes Ecotone available in a Tempest app with zero config, the same wayecotone/laraveldoes for Laravel andecotone/symfony-bundlefor Symfony.Description of Changes
New package:
packages/Tempest(ecotone/tempest)MessagingSystemInitializer(TempestInitializer) andEcotoneServiceInitializer(TempestDynamicInitializer) compile Ecotone lazily on first gateway request. All gateways (CommandBus,QueryBus,EventBus, custom business interfaces) are injectable from Tempest's container with no setup.loadAppNamespaces: true(default), Ecotone auto-derives scan namespaces from Tempest'sComposer->namespaces(the app's PSR-4 roots), mirroring Laravel'sapp/catalog scan. Anecotone.config.phpis optional, not required.TempestConnectionReferencewraps the app's TempestDatabaseConfig(Postgres/MySQL/SQLite) as Ecotone'sDbalConnectionFactory, enabling outbox, event sourcing, dead-letter queues, and DBAL business interfaces off the app's existing DB config.TempestRepositorylets TempestIsDatabaseModelmodels be used directly as Ecotone aggregates (canHandle/findBy/save), with a recursive trait check for detection.TempestTenantDatabaseSwitcher+TempestConnectionModuleintegrateHeaderBasedMultiTenantConnectionFactoryso commands/queries route to the correct tenant database via a message header.EcotoneConsoleCommandDiscoverygenerates#[ConsoleCommand]proxy classes for every registered Ecotone command (ecotone:list,ecotone:run,ecotone:cache:clear, etc.) so they appear natively in./tempestwith no manual wiring.ecotone:cache:clearcommand, real-boot test proving zero-config works end-to-end.Also:
ModulePackageList::TEMPEST_PACKAGEadded to Ecotone core (mirroringLARAVEL_PACKAGE/SYMFONY_PACKAGE,class_exists-guarded).Usage
Install:
Zero-config — handlers in
App\are auto-discovered, gateways are injectable:Optional
ecotone.config.php(service name, licence key, explicit namespaces):DBAL connection — wire the app's DB config as Ecotone's default connection:
Active-record aggregate — use a Tempest model as an Ecotone aggregate:
Multi-tenant routing — route commands to the correct tenant DB via header:
Integration architecture
flowchart TD A[Tempest boot\nFrameworkKernel] -->|InitializerDiscovery| B[EcotoneServiceInitializer\nDynamicInitializer] A -->|DiscoveryDiscovery| C[EcotoneConsoleCommandDiscovery\nDiscovery] B -->|first get CommandBus| D[MessagingSystemInitializer\ncompile Ecotone] D -->|derives from Composer.namespaces| E[App PSR-4 roots\nApp handler scan] D -->|LazyInMemoryContainer| F[ConfiguredMessagingSystem] F -->|markCompiled| B B -->|DynamicInitializer| G[CommandBus / QueryBus\nEventBus / BusinessInterface\nresolvable from container] C -->|ConsoleCommandProxyGenerator| H[ConsoleCommand proxies\necotone:list / ecotone:run] D -->|TempestConnectionModule| I[TempestConnectionReference\nDbalConnectionFactory] D -->|TempestRepositoryBuilder| J[TempestRepository\nIsDatabaseModel as Aggregate]Use-case scenarios
CQRS + Event Sourcing in a Tempest app — Install
ecotone/tempest+ecotone/pdo-event-sourcing, define#[EventSourcingAggregate]classes and projections, wire the DB viaTempestConnectionReference. See the included quickstart (quickstart-examples/Tempest/Projection/DatabaseReadModel).Tempest active-record model as a DDD aggregate — Use
IsDatabaseModel+#[Aggregate]so commands mutate the model through the Command Bus andTempestRepositoryhandles persistence automatically. Seequickstart-examples/Tempest/Model.Multi-tenant SaaS — Use
MultiTenantConfigurationwithTempestConnectionReferenceper tenant; atenantmessage header routes every command/query to the correct database automatically (proven across Postgres + MySQL in tests).Pull Request Contribution Terms