From 726a0512a8fce9d0ce3c6317b598d4a976820c27 Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Fri, 16 Oct 2026 21:59:36 +0600 Subject: [PATCH] Request-scoped lifecycle and context propagation --- src/Phaseolies/Application.php | 48 +- src/Phaseolies/Console/Console.php | 6 +- .../InteractsWithExecutionContexts.php | 353 +++++++++ src/Phaseolies/DI/Container.php | 97 ++- src/Phaseolies/Helpers/helpers.php | 12 + src/Phaseolies/Http/DispatchResult.php | 6 +- src/Phaseolies/Launchers/FacadeLauncher.php | 7 + src/Phaseolies/Support/ExecutionContext.php | 165 ++++ src/Phaseolies/Support/ExecutionService.php | 125 +++ src/Phaseolies/Support/Facades/App.php | 2 + src/Phaseolies/Support/Facades/Execution.php | 27 + src/Phaseolies/Support/LoggerService.php | 34 +- tests/Application/ExecutionContextTest.php | 734 ++++++++++++++++++ tests/Logger/ExecutionContextLoggingTest.php | 99 +++ 14 files changed, 1694 insertions(+), 21 deletions(-) create mode 100644 src/Phaseolies/DI/Concerns/InteractsWithExecutionContexts.php create mode 100644 src/Phaseolies/Support/ExecutionContext.php create mode 100644 src/Phaseolies/Support/ExecutionService.php create mode 100644 src/Phaseolies/Support/Facades/Execution.php create mode 100644 tests/Application/ExecutionContextTest.php create mode 100644 tests/Logger/ExecutionContextLoggingTest.php diff --git a/src/Phaseolies/Application.php b/src/Phaseolies/Application.php index ccb10c01..17c01aa2 100644 --- a/src/Phaseolies/Application.php +++ b/src/Phaseolies/Application.php @@ -15,6 +15,7 @@ use Phaseolies\DI\Container; use Phaseolies\Config\Config; use Phaseolies\Support\DeferScope; +use Phaseolies\Support\ExecutionContext; use Phaseolies\ApplicationBuilder; use Devium\Toml\Toml; use RuntimeException; @@ -1138,8 +1139,13 @@ public function terminate( Request $request, ?Response $response = null, ?\Throwable $exception = null, - ?DeferScope $deferScope = null + ?DeferScope $deferScope = null, + ?ExecutionContext $context = null ): void { + if ($context !== null) { + $this->resumeContext($context); + } + if ($deferScope !== null) { // Make the request scope current again so terminating callbacks // can still defer cleanup into it. @@ -1164,7 +1170,13 @@ public function terminate( $this->endDeferScope($deferScope, $terminationFailure ?? $exception); } } finally { - $this->cleanupRequestScopedServices(); + try { + $this->cleanupRequestScopedServices(); + } finally { + if ($context !== null) { + $this->endContext($context); + } + } } } } @@ -1512,6 +1524,7 @@ public function dispatch($request): DispatchResult { $this->snapshotBootBindings(); + $context = $this->beginContext(ExecutionContext::REQUEST, $this->requestContextMetadata($request)); $deferScope = $this->beginDeferScope(); try { @@ -1522,24 +1535,51 @@ public function dispatch($request): DispatchResult $response->prepare($request)->send(); $this->suspendDeferScope($deferScope); + $this->suspendContext($context); - return new DispatchResult($this, $request, $response, null, $deferScope); + return new DispatchResult($this, $request, $response, null, $deferScope, $context); } catch (HttpException $e) { try { Response::dispatchHttpException($e); } catch (\Throwable $renderFailure) { $this->endDeferScope($deferScope, $renderFailure); + $this->endContext($context); throw $renderFailure; } $this->suspendDeferScope($deferScope); + $this->suspendContext($context); - return new DispatchResult($this, $request, null, $e, $deferScope); + return new DispatchResult($this, $request, null, $e, $deferScope, $context); } catch (\Throwable $e) { $this->endDeferScope($deferScope, $e); + $this->endContext($context); throw $e; } } + + /** + * Describe a request for its execution context. Must never throw. + * + * @param mixed $request + * @return array + */ + protected function requestContextMetadata($request): array + { + $metadata = []; + + try { + $metadata = array_filter([ + 'method' => $request->method(), + 'path' => $request->getPath(), + 'request_id' => $request->header('X-Request-Id'), + ], static fn($value) => $value !== null && $value !== ''); + } catch (\Throwable) { + // Metadata is best-effort; the request must still be served. + } + + return $metadata; + } } diff --git a/src/Phaseolies/Console/Console.php b/src/Phaseolies/Console/Console.php index ca12903c..a85617ed 100644 --- a/src/Phaseolies/Console/Console.php +++ b/src/Phaseolies/Console/Console.php @@ -97,6 +97,10 @@ protected function bootstrapCommands(): void */ protected function doRunCommand(Command $command, InputInterface $input, OutputInterface $output): int { - return $this->app->runInDeferScope(fn() => parent::doRunCommand($command, $input, $output)); + return $this->app->runInContext( + 'console', + ['command' => $command->getName()], + fn() => $this->app->runInDeferScope(fn() => parent::doRunCommand($command, $input, $output)) + ); } } diff --git a/src/Phaseolies/DI/Concerns/InteractsWithExecutionContexts.php b/src/Phaseolies/DI/Concerns/InteractsWithExecutionContexts.php new file mode 100644 index 00000000..c4d50480 --- /dev/null +++ b/src/Phaseolies/DI/Concerns/InteractsWithExecutionContexts.php @@ -0,0 +1,353 @@ + + */ + private array $contextStack = []; + + /** + * Contexts that have begun and not yet ended, including suspended ones + * + * @var array + */ + private array $liveContexts = []; + + /** + * Resolved execution-scoped instances, keyed by context id then abstract. + * + * @var array> + */ + private array $scopedInstances = []; + + /** + * Lifecycle listeners. + * + * @var array{begin: array, end: array} + */ + private array $contextListeners = ['begin' => [], 'end' => []]; + + /** + * Begin a context nested inside the current one (if any) and make it current. + * + * @param string $type + * @param array $metadata + * @return ExecutionContext + */ + public function beginContext(string $type, array $metadata = []): ExecutionContext + { + $context = ExecutionContext::create($type, $metadata, $this->executionContext()); + + $this->contextStack[] = $context; + $this->liveContexts[$context->id()] = $context; + + try { + $this->fireContextListeners('begin', $context); + } catch (\Throwable $e) { + $this->endContext($context); + + throw $e; + } + + return $context; + } + + /** + * End a context: end any contexts nested inside it, run the end + * + * @param ExecutionContext $context + * @return void + * + * @throws \Throwable + */ + public function endContext(ExecutionContext $context): void + { + $index = $this->contextIndex($context); + + if ($index === null) { + if (!isset($this->liveContexts[$context->id()])) { + return; + } + + // Suspended: put its latest copy back on top so listeners and + // teardown see it as current. + $this->contextStack[] = $this->liveContexts[$context->id()]; + $index = count($this->contextStack) - 1; + } + + $failure = null; + + while (count($this->contextStack) > $index) { + $ending = end($this->contextStack); + + try { + $this->fireContextListeners('end', $ending); + } catch (\Throwable $e) { + $failure ??= $e; + } + + unset($this->scopedInstances[$ending->id()], $this->liveContexts[$ending->id()]); + array_pop($this->contextStack); + } + + if ($failure !== null) { + throw $failure; + } + } + + /** + * Run a callback inside a fresh context that always ends afterwards. + * + * @param string $type + * @param array $metadata + * @param callable $callback + * @return mixed + */ + public function runInContext(string $type, array $metadata, callable $callback): mixed + { + $context = $this->beginContext($type, $metadata); + + try { + return $callback($context); + } finally { + $this->endContext($context); + } + } + + /** + * Detach a context from the stack without ending it, so it can outlive the call that created it + * + * @param ExecutionContext $context + * @return void + */ + public function suspendContext(ExecutionContext $context): void + { + $index = $this->contextIndex($context); + + if ($index === null) { + return; + } + + while (count($this->contextStack) > $index + 1) { + $this->endContext(end($this->contextStack)); + } + + array_pop($this->contextStack); + } + + /** + * Make a suspended context current again. + * + * @param ExecutionContext $context + * @return void + */ + public function resumeContext(ExecutionContext $context): void + { + // An ended context stays ended, and a context that is already + // current needs no resuming. + if (!isset($this->liveContexts[$context->id()]) || $this->contextIndex($context) !== null) { + return; + } + + $this->contextStack[] = $this->liveContexts[$context->id()]; + } + + /** + * The innermost active context, if any. + * + * @return ExecutionContext|null + */ + public function executionContext(): ?ExecutionContext + { + return $this->contextStack === [] ? null : end($this->contextStack); + } + + /** + * Whether there is an active context + * + * @return bool + */ + public function hasExecutionContext(): bool + { + return $this->contextStack !== []; + } + + /** + * Merge metadata into the current context + * + * @param array $metadata + * @return ExecutionContext + * + * @throws \LogicException + */ + public function stampContext(array $metadata): ExecutionContext + { + $current = $this->executionContext(); + + if ($current === null) { + throw new \LogicException('Cannot add metadata: no execution context is active.'); + } + + $stamped = $current->withMetadata($metadata); + + $this->contextStack[array_key_last($this->contextStack)] = $stamped; + $this->liveContexts[$stamped->id()] = $stamped; + + return $stamped; + } + + /** + * Register a listener for contexts beginning. + * + * @param callable(ExecutionContext): void $listener + * @param string|null $type + * @return void + */ + public function contextBeginning(callable $listener, ?string $type = null): void + { + $this->contextListeners['begin'][] = [$listener, $type]; + } + + /** + * Register a listener for contexts ending. + * + * @param callable(ExecutionContext): void $listener + * @param string|null $type + * @return void + */ + public function contextEnding(callable $listener, ?string $type = null): void + { + $this->contextListeners['end'][] = [$listener, $type]; + } + + /** + * Get (or build and remember) the instance of an execution-scoped binding for the current context. + * + * @param string $abstract + * @param callable $factory + * @return mixed + * + * @throws \LogicException + */ + private function executionScopedInstance(string $abstract, callable $factory): mixed + { + $context = $this->executionContext(); + + if ($context === null) { + throw new \LogicException( + "Cannot resolve execution-scoped service [{$abstract}] outside an execution context. " + . 'Resolve it during an HTTP dispatch, a console command or inside runInContext().' + ); + } + + $id = $context->id(); + + if (!array_key_exists($abstract, $this->scopedInstances[$id] ?? [])) { + $this->scopedInstances[$id][$abstract] = $factory(); + } + + return $this->scopedInstances[$id][$abstract]; + } + + /** + * Forget one abstract's instance in every context. + * + * @param string $abstract + * @return void + */ + private function forgetScopedInstance(string $abstract): void + { + foreach ($this->scopedInstances as $id => $instances) { + unset($this->scopedInstances[$id][$abstract]); + } + } + + /** + * End every context that is still open, innermost first and suspended ones last + * + * @return void + */ + private function resetExecutionContexts(): void + { + $failure = null; + + try { + while ($this->contextStack !== []) { + try { + $this->endContext(end($this->contextStack)); + } catch (\Throwable $e) { + $failure ??= $e; + } + } + + foreach ($this->liveContexts as $suspended) { + try { + $this->endContext($suspended); + } catch (\Throwable $e) { + $failure ??= $e; + } + } + } finally { + $this->contextStack = []; + $this->liveContexts = []; + $this->scopedInstances = []; + $this->contextListeners = ['begin' => [], 'end' => []]; + } + + if ($failure !== null) { + throw $failure; + } + } + + /** + * Get the index of a context in the stack, or null if it is not active + * + * @param ExecutionContext $context + * @return int|null + */ + private function contextIndex(ExecutionContext $context): ?int + { + foreach ($this->contextStack as $index => $active) { + if ($active->id() === $context->id()) { + return $index; + } + } + + return null; + } + + /** + * Fire all listeners for a context event, skipping those that don't match the context type + * + * @param string $event + * @param ExecutionContext $context + * @return void + */ + private function fireContextListeners(string $event, ExecutionContext $context): void + { + $failure = null; + + foreach ($this->contextListeners[$event] as [$listener, $type]) { + if ($type !== null && $type !== $context->type()) { + continue; + } + + try { + $listener($context); + } catch (\Throwable $e) { + $failure ??= $e; + } + } + + if ($failure !== null) { + throw $failure; + } + } +} diff --git a/src/Phaseolies/DI/Container.php b/src/Phaseolies/DI/Container.php index ea3167c3..9484f57e 100644 --- a/src/Phaseolies/DI/Container.php +++ b/src/Phaseolies/DI/Container.php @@ -4,9 +4,18 @@ use ArrayAccess; use Phaseolies\DI\Attributes\Immutable; +use Phaseolies\DI\Concerns\InteractsWithExecutionContexts; class Container implements ArrayAccess { + use InteractsWithExecutionContexts; + + public const SCOPE_SINGLETON = 'singleton'; + + public const SCOPE_EXECUTION = 'execution'; + + public const SCOPE_TRANSIENT = 'transient'; + /** * Array to hold service definitions. * @@ -108,6 +117,7 @@ public function offsetSet($offset, $value): void public function offsetUnset($offset): void { unset($this->bindings[$offset], $this->instances[$offset]); + $this->forgetScopedInstance($offset); } /** @@ -134,6 +144,67 @@ public function bind(string $abstract, callable|string|null $concrete = null, bo } } + /** + * Bind a service whose instance lives for one execution context + * + * @param string $abstract + * @param callable|string|null $concrete + * @return void + */ + public function bindExecution(string $abstract, callable|string|null $concrete = null): void + { + $this->bindWithScope($abstract, $concrete, self::SCOPE_EXECUTION); + } + + /** + * Bind a service with an explicit scope: singleton, execution or transient. + * + * @param string $abstract + * @param callable|string|null $concrete + * @param string $scope + * @return void + * + * @throws \InvalidArgumentException + */ + public function bindWithScope(string $abstract, callable|string|null $concrete, string $scope): void + { + match ($scope) { + self::SCOPE_SINGLETON => $this->bind($abstract, $concrete, true), + self::SCOPE_TRANSIENT => $this->bind($abstract, $concrete, false), + self::SCOPE_EXECUTION => $this->bindings[$abstract] = [ + 'concrete' => $concrete ?? $abstract, + 'singleton' => false, + 'scope' => self::SCOPE_EXECUTION, + ], + default => throw new \InvalidArgumentException( + "Unknown binding scope [{$scope}]. Use singleton, execution or transient." + ), + }; + + if ($scope !== self::SCOPE_SINGLETON) { + unset($this->instances[$abstract]); + } + + $this->forgetScopedInstance($abstract); + } + + /** + * Get the scope a binding was declared with. + * + * @param string $abstract + * @return string|null Null when nothing is bound. + */ + public function scopeOf(string $abstract): ?string + { + if (!isset($this->bindings[$abstract])) { + return null; + } + + $binding = $this->bindings[$abstract]; + + return $binding['scope'] ?? ($binding['singleton'] ? self::SCOPE_SINGLETON : self::SCOPE_TRANSIENT); + } + /** * Bind a singleton service to the container. * @@ -195,6 +266,14 @@ public function get(string $abstract, array $parameters = []): mixed if (isset($this->bindings[$abstract])) { $binding = $this->bindings[$abstract]; + + if (($binding['scope'] ?? null) === self::SCOPE_EXECUTION) { + return $this->executionScopedInstance( + $abstract, + fn() => $this->resolveBinding($abstract, $binding, $parameters) + ); + } + $resolved = $this->resolveBinding($abstract, $binding, $parameters); if ($binding['singleton']) { @@ -431,6 +510,7 @@ public function hasInstance(string $key): bool public function forgetResolved(string $abstract): void { unset($this->instances[$abstract]); + $this->forgetScopedInstance($abstract); } /** @@ -475,11 +555,15 @@ public function forgetRequestScopedInstances(): void */ public function flush(): void { - $this->bindings = []; - $this->instances = []; - $this->resolving = []; - $this->bootBindingKeys = []; - $this->bootBindingsSnapshotted = false; + try { + $this->resetExecutionContexts(); + } finally { + $this->bindings = []; + $this->instances = []; + $this->resolving = []; + $this->bootBindingKeys = []; + $this->bootBindingsSnapshotted = false; + } } /** @@ -566,7 +650,8 @@ public function extend(string $abstract, callable $extender): void $this->bindings[$abstract] = [ 'concrete' => fn(Container $container, array $parameters = []) => $extender($container->resolveBinding($abstract, $previous, $parameters), $container), - 'singleton' => $previous['singleton'] + 'singleton' => $previous['singleton'], + 'scope' => $previous['scope'] ?? ($previous['singleton'] ? self::SCOPE_SINGLETON : self::SCOPE_TRANSIENT), ]; } diff --git a/src/Phaseolies/Helpers/helpers.php b/src/Phaseolies/Helpers/helpers.php index a42bf2f6..bb2b37de 100644 --- a/src/Phaseolies/Helpers/helpers.php +++ b/src/Phaseolies/Helpers/helpers.php @@ -109,6 +109,18 @@ function signal(?object $signal = null) } } +if (!function_exists('execution')) { + /** + * Get the execution service, the accessor for the active execution context + * + * @return \Phaseolies\Support\ExecutionService + */ + function execution() + { + return app('execution'); + } +} + if (!function_exists('request')) { /** * Creates a new request instance to handle HTTP requests. diff --git a/src/Phaseolies/Http/DispatchResult.php b/src/Phaseolies/Http/DispatchResult.php index cebd4864..1fbdcd32 100644 --- a/src/Phaseolies/Http/DispatchResult.php +++ b/src/Phaseolies/Http/DispatchResult.php @@ -4,6 +4,7 @@ use Phaseolies\Application; use Phaseolies\Support\DeferScope; +use Phaseolies\Support\ExecutionContext; class DispatchResult { @@ -25,7 +26,8 @@ public function __construct( protected Request $request, protected ?Response $response = null, protected ?\Throwable $exception = null, - protected ?DeferScope $deferScope = null + protected ?DeferScope $deferScope = null, + protected ?ExecutionContext $context = null ) {} /** @@ -40,7 +42,7 @@ public function terminate(): static } $this->terminated = true; - $this->app->terminate($this->request, $this->response, $this->exception, $this->deferScope); + $this->app->terminate($this->request, $this->response, $this->exception, $this->deferScope, $this->context); return $this; } diff --git a/src/Phaseolies/Launchers/FacadeLauncher.php b/src/Phaseolies/Launchers/FacadeLauncher.php index 1094ae8a..5dbb30c9 100644 --- a/src/Phaseolies/Launchers/FacadeLauncher.php +++ b/src/Phaseolies/Launchers/FacadeLauncher.php @@ -10,6 +10,7 @@ use Phaseolies\Signals\SignalBus; use Phaseolies\Support\Session; use Phaseolies\Support\Mail\MailService; +use Phaseolies\Support\ExecutionService; use Phaseolies\Support\LoggerService; use Phaseolies\Support\Encryption; use Phaseolies\Support\CookieJar; @@ -88,6 +89,12 @@ public function register() // This handles user define log. $this->app->singleton('log', LoggerService::class); + // Bind the 'execution' service: a thin accessor over the container's + // active execution context (request, console command, worker job). + $this->app->singleton('execution', function () { + return new ExecutionService($this->app); + }); + // Bind the 'sanitize' service to a singleton instance of the Sanitizer class. // This handles user requested form data. $this->app->singleton('sanitize', function () { diff --git a/src/Phaseolies/Support/ExecutionContext.php b/src/Phaseolies/Support/ExecutionContext.php new file mode 100644 index 00000000..6680e7c6 --- /dev/null +++ b/src/Phaseolies/Support/ExecutionContext.php @@ -0,0 +1,165 @@ + $metadata + * @param ExecutionContext|null $parent + * @param float $startedAt + */ + private function __construct( + private readonly string $id, + private readonly string $type, + private readonly array $metadata, + private readonly ?ExecutionContext $parent, + private readonly float $startedAt + ) {} + + /** + * Create a new context, inheriting the parent's metadata when given. + * + * @param string $type + * @param array $metadata + * @param ExecutionContext|null $parent + * @return self + */ + public static function create(string $type, array $metadata = [], ?self $parent = null): self + { + if ($type === '') { + throw new \InvalidArgumentException('An execution context type cannot be empty.'); + } + + return new self( + id: bin2hex(random_bytes(8)), + type: $type, + metadata: array_replace($parent?->all() ?? [], $metadata), + parent: $parent, + startedAt: microtime(true) + ); + } + + /** + * Return a copy of this context (same identity) with extra metadata merged in. + * + * @param array $metadata + * @return self + */ + public function withMetadata(array $metadata): self + { + return new self( + $this->id, + $this->type, + array_replace($this->metadata, $metadata), + $this->parent, + $this->startedAt + ); + } + + /** + * Unique identifier of this context, stable across withMetadata() copies. + * + * @return string + */ + public function id(): string + { + return $this->id; + } + + /** + * Type of this context (request, console, worker, etc.). + * + * @return string + */ + public function type(): string + { + return $this->type; + } + + /** + * Return the parent context, or null if this is a root context. + * + * @return ExecutionContext|null + */ + public function parent(): ?self + { + return $this->parent; + } + + /** + * Number of ancestors above this context (0 for a root context). + * + * @return int + */ + public function depth(): int + { + return $this->parent === null ? 0 : $this->parent->depth() + 1; + } + + /** + * Unix timestamp (with microseconds) at which the context began. + * + * @return float + */ + public function startedAt(): float + { + return $this->startedAt; + } + + /** + * Get a metadata value from this context (or any ancestor), or return the default if not found. + * + * @param string $key + * @param mixed $default + * @return mixed + */ + public function get(string $key, mixed $default = null): mixed + { + return array_key_exists($key, $this->metadata) ? $this->metadata[$key] : $default; + } + + /** + * Check if a metadata key exists in this context (or any ancestor). + * + * @param string $key + * @return bool + */ + public function has(string $key): bool + { + return array_key_exists($key, $this->metadata); + } + + /** + * All metadata, including values inherited from ancestors. + * + * @return array + */ + public function all(): array + { + return $this->metadata; + } + + /** + * A flat, log-friendly representation. + * + * @return array + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'type' => $this->type, + 'parent_id' => $this->parent?->id(), + 'metadata' => $this->metadata, + ]; + } +} diff --git a/src/Phaseolies/Support/ExecutionService.php b/src/Phaseolies/Support/ExecutionService.php new file mode 100644 index 00000000..a4361914 --- /dev/null +++ b/src/Phaseolies/Support/ExecutionService.php @@ -0,0 +1,125 @@ +container->executionContext(); + } + + /** + * Determine if we are currently running within an execution context. + * + * @return bool + */ + public function running(): bool + { + return $this->container->hasExecutionContext(); + } + + /** + * Get the ID of the active context, or null if not running within one. + * + * @return string|null + */ + public function id(): ?string + { + return $this->current()?->id(); + } + + /** + * Get the type of the active context, or null if not running within one. + * + * @return string|null + */ + public function type(): ?string + { + return $this->current()?->type(); + } + + /** + * Get a value from the active context, or return a default if not running within one. + * + * @param string $key + * @param mixed $default + * @return mixed + */ + public function get(string $key, mixed $default = null): mixed + { + $context = $this->current(); + + return $context !== null && $context->has($key) ? $context->get($key) : $default; + } + + /** + * Get all values from the active context, or an empty array if not running within one. + * + * @return array + */ + public function all(): array + { + return $this->current()?->all() ?? []; + } + + /** + * Stamp metadata (a tenant id, a user id, ...) onto the active context. + * + * @param array $metadata + * @return ExecutionContext + */ + public function stamp(array $metadata): ExecutionContext + { + return $this->container->stampContext($metadata); + } + + /** + * Run a callback within a nested context. + * + * @param string $type + * @param array $metadata + * @param callable $callback + * @return mixed + */ + public function within(string $type, array $metadata, callable $callback): mixed + { + return $this->container->runInContext($type, $metadata, $callback); + } + + /** + * Register a listener to be called when a context of the given type begins. + * + * @param callable(ExecutionContext): void $listener + * @param string|null $type + * @return void + */ + public function beginning(callable $listener, ?string $type = null): void + { + $this->container->contextBeginning($listener, $type); + } + + /** + * Register a listener to be called when a context of the given type ends. + * + * @param callable(ExecutionContext): void $listener + * @param string|null $type + * @return void + */ + public function ending(callable $listener, ?string $type = null): void + { + $this->container->contextEnding($listener, $type); + } +} diff --git a/src/Phaseolies/Support/Facades/App.php b/src/Phaseolies/Support/Facades/App.php index ef9be9ff..c2460c63 100644 --- a/src/Phaseolies/Support/Facades/App.php +++ b/src/Phaseolies/Support/Facades/App.php @@ -27,6 +27,8 @@ * @method static \Phaseolies\Application defer(callable $callback) * @method static mixed runInDeferScope(callable $callback) * @method static bool hasDeferScope() + * @method static \Phaseolies\Support\ExecutionContext|null executionContext() + * @method static mixed runInContext(string $type, array $metadata, callable $callback) * * @see \Phaseolies\Application */ diff --git a/src/Phaseolies/Support/Facades/Execution.php b/src/Phaseolies/Support/Facades/Execution.php new file mode 100644 index 00000000..bc2a1ff4 --- /dev/null +++ b/src/Phaseolies/Support/Facades/Execution.php @@ -0,0 +1,27 @@ +formatMessage($message); - $this->reader()->info($message, $context); + $this->reader()->info($message, $this->withExecutionContext($context)); } /** @@ -65,7 +66,7 @@ public function notice(mixed $message, array $context = []): void { $message = $this->formatMessage($message); - $this->reader()->notice($message, $context); + $this->reader()->notice($message, $this->withExecutionContext($context)); } /** @@ -79,7 +80,7 @@ public function warning(mixed $message, array $context = []): void { $message = $this->formatMessage($message); - $this->reader()->warning($message, $context); + $this->reader()->warning($message, $this->withExecutionContext($context)); } /** @@ -93,7 +94,7 @@ public function error(mixed $message, array $context = []): void { $message = $this->formatMessage($message); - $this->reader()->error($message, $context); + $this->reader()->error($message, $this->withExecutionContext($context)); } /** @@ -107,7 +108,7 @@ public function debug(mixed $message, array $context = []): void { $message = $this->formatMessage($message); - $this->reader()->debug($message, $context); + $this->reader()->debug($message, $this->withExecutionContext($context)); } /** @@ -121,7 +122,7 @@ public function critical(mixed $message, array $context = []): void { $message = $this->formatMessage($message); - $this->reader()->critical($message, $context); + $this->reader()->critical($message, $this->withExecutionContext($context)); } /** @@ -135,7 +136,7 @@ public function alert(mixed $message, array $context = []): void { $message = $this->formatMessage($message); - $this->reader()->alert($message, $context); + $this->reader()->alert($message, $this->withExecutionContext($context)); } /** @@ -149,7 +150,24 @@ public function emergency(mixed $message, array $context = []): void { $message = $this->formatMessage($message); - $this->reader()->emergency($message, $context); + $this->reader()->emergency($message, $this->withExecutionContext($context)); + } + + /** + * Attach the active execution context (id, type, metadata) to a log + * + * @param array $context + * @return array + */ + protected function withExecutionContext(array $context): array + { + $execution = Container::getInstance()->executionContext(); + + if ($execution === null || array_key_exists('execution', $context)) { + return $context; + } + + return $context + ['execution' => $execution->toArray()]; } /** diff --git a/tests/Application/ExecutionContextTest.php b/tests/Application/ExecutionContextTest.php new file mode 100644 index 00000000..b9255b9f --- /dev/null +++ b/tests/Application/ExecutionContextTest.php @@ -0,0 +1,734 @@ +container = new Container(); + } + + // ---------------------------------------------------------------- value object + + public function testContextIsImmutableAndWithMetadataKeepsIdentity(): void + { + $context = ExecutionContext::create('request', ['path' => '/a']); + $copy = $context->withMetadata(['tenant' => 7]); + + $this->assertNotSame($context, $copy); + $this->assertSame($context->id(), $copy->id()); + $this->assertFalse($context->has('tenant')); + $this->assertSame(7, $copy->get('tenant')); + $this->assertSame('/a', $copy->get('path')); + } + + public function testEmptyTypeIsRejected(): void + { + $this->expectException(\InvalidArgumentException::class); + + ExecutionContext::create(''); + } + + public function testChildInheritsAndOverridesParentMetadata(): void + { + $parent = ExecutionContext::create('request', ['tenant' => 1, 'path' => '/x']); + $child = ExecutionContext::create('worker', ['tenant' => 2], $parent); + + $this->assertSame(2, $child->get('tenant')); + $this->assertSame('/x', $child->get('path')); + $this->assertSame($parent, $child->parent()); + $this->assertSame(1, $child->depth()); + $this->assertSame($parent->id(), $child->toArray()['parent_id']); + $this->assertSame(1, $parent->get('tenant'), 'the parent must be untouched'); + } + + // ---------------------------------------------------------------- creation / nesting / teardown + + public function testNoContextIsActiveByDefault(): void + { + $this->assertNull($this->container->executionContext()); + $this->assertFalse($this->container->hasExecutionContext()); + } + + public function testBeginAndEndMaintainTheStack(): void + { + $outer = $this->container->beginContext('request'); + $inner = $this->container->beginContext('worker'); + + $this->assertSame($inner, $this->container->executionContext()); + $this->assertSame($outer, $inner->parent()); + + $this->container->endContext($inner); + $this->assertSame($outer, $this->container->executionContext()); + + $this->container->endContext($outer); + $this->assertNull($this->container->executionContext()); + } + + public function testEndingAnOuterContextEndsNestedOnesFirst(): void + { + $order = []; + $this->container->contextEnding(function (ExecutionContext $c) use (&$order) { + $order[] = $c->type(); + }); + + $outer = $this->container->beginContext('request'); + $this->container->beginContext('worker'); + + $this->container->endContext($outer); + + $this->assertSame(['worker', 'request'], $order); + $this->assertNull($this->container->executionContext()); + } + + public function testEndingTwiceIsANoOp(): void + { + $ended = 0; + $this->container->contextEnding(function () use (&$ended) { + $ended++; + }); + + $context = $this->container->beginContext('console'); + $this->container->endContext($context); + $this->container->endContext($context); + + $this->assertSame(1, $ended); + } + + public function testRunInContextEndsEvenWhenTheCallbackThrows(): void + { + try { + $this->container->runInContext('console', [], function () { + throw new \RuntimeException('boom'); + }); + $this->fail('expected exception'); + } catch (\RuntimeException) { + } + + $this->assertNull($this->container->executionContext()); + } + + public function testListenersFilterByTypeAndRunBeforeScopedInstancesAreDropped(): void + { + $this->container->bindExecution('svc', fn() => new \stdClass()); + + $seen = []; + $this->container->contextBeginning(function (ExecutionContext $c) use (&$seen) { + $seen[] = 'begin:' . $c->type(); + }, 'request'); + $this->container->contextEnding(function (ExecutionContext $c) use (&$seen) { + $seen[] = 'end:' . $c->type(); + $seen[] = get_class($this->container->get('svc')); + }); + + $this->container->runInContext('console', [], fn() => null); + $this->container->runInContext('request', [], fn() => null); + + $this->assertSame(['end:console', \stdClass::class, 'begin:request', 'end:request', \stdClass::class], $seen); + } + + public function testFailingEndListenerStillTearsTheContextDown(): void + { + $this->container->contextEnding(function () { + throw new \RuntimeException('listener'); + }); + + $context = $this->container->beginContext('request'); + + try { + $this->container->endContext($context); + $this->fail('expected exception'); + } catch (\RuntimeException $e) { + $this->assertSame('listener', $e->getMessage()); + } + + $this->assertNull($this->container->executionContext()); + } + + public function testWithContextMetadataReplacesTheCurrentContextKeepingItsId(): void + { + $context = $this->container->beginContext('request'); + $updated = $this->container->stampContext(['tenant' => 'acme']); + + $this->assertSame($context->id(), $updated->id()); + $this->assertSame($updated, $this->container->executionContext()); + $this->assertFalse($context->has('tenant'), 'the original instance is immutable'); + + $this->container->endContext($context); + } + + public function testWithContextMetadataRequiresAnActiveContext(): void + { + $this->expectException(\LogicException::class); + + $this->container->stampContext(['a' => 1]); + } + + public function testSuspendedContextCanBeResumedAndEnded(): void + { + $ended = 0; + $this->container->contextEnding(function () use (&$ended) { + $ended++; + }); + + $context = $this->container->beginContext('request'); + $this->container->suspendContext($context); + + $this->assertNull($this->container->executionContext()); + $this->assertSame(0, $ended); + + $this->container->resumeContext($context); + $this->assertSame($context, $this->container->executionContext()); + + $this->container->endContext($context); + $this->assertSame(1, $ended); + } + + public function testSuspendedContextWithoutScopedInstancesStillFiresEndListeners(): void + { + $ended = 0; + $this->container->contextEnding(function () use (&$ended) { + $ended++; + }); + + $context = $this->container->beginContext('request'); + $this->container->suspendContext($context); + $this->container->endContext($context); + + $this->assertSame(1, $ended); + } + + // ---------------------------------------------------------------- scopes + + public function testExecutionScopedServiceIsSharedWithinAContextAndNotAcrossContexts(): void + { + $this->container->bindExecution('svc', fn() => new \stdClass()); + + $first = $this->container->runInContext('request', [], function () { + $a = $this->container->get('svc'); + $this->assertSame($a, $this->container->get('svc')); + + return $a; + }); + + $second = $this->container->runInContext('request', [], fn() => $this->container->get('svc')); + + $this->assertNotSame($first, $second, 'a scoped instance must not leak into the next execution'); + } + + public function testNestedContextGetsItsOwnScopedInstanceAndOuterOneSurvives(): void + { + $this->container->bindExecution('svc', fn() => new \stdClass()); + + $this->container->runInContext('request', [], function () { + $outer = $this->container->get('svc'); + + $inner = $this->container->runInContext('worker', [], fn() => $this->container->get('svc')); + + $this->assertNotSame($outer, $inner); + $this->assertSame($outer, $this->container->get('svc')); + }); + } + + public function testExecutionScopedServiceOutsideAContextFailsLoudly(): void + { + $this->container->bindExecution('svc', fn() => new \stdClass()); + + $this->expectException(\LogicException::class); + + $this->container->get('svc'); + } + + public function testScopeOfReportsDeclaredScopes(): void + { + $this->container->singleton('a', fn() => 1); + $this->container->bind('b', fn() => 1); + $this->container->bindExecution('c', fn() => 1); + $this->container->bindWithScope('d', fn() => 1, 'execution'); + + $this->assertSame('singleton', $this->container->scopeOf('a')); + $this->assertSame('transient', $this->container->scopeOf('b')); + $this->assertSame('execution', $this->container->scopeOf('c')); + $this->assertSame('execution', $this->container->scopeOf('d')); + $this->assertNull($this->container->scopeOf('missing')); + } + + public function testUnknownScopeIsRejected(): void + { + $this->expectException(\InvalidArgumentException::class); + + $this->container->bindWithScope('x', fn() => 1, 'forever'); + } + + public function testSingletonAndTransientBehaviourIsUnchanged(): void + { + $this->container->singleton('single', fn() => new \stdClass()); + $this->container->bind('transient', fn() => new \stdClass()); + + $this->assertSame($this->container->get('single'), $this->container->get('single')); + $this->assertNotSame($this->container->get('transient'), $this->container->get('transient')); + + $this->container->runInContext('request', [], function () { + $this->assertSame($this->container->get('single'), $this->container->get('single')); + }); + + $this->assertTrue($this->container->isSingleton('single')); + $this->assertFalse($this->container->isSingleton('transient')); + } + + public function testExtendKeepsExecutionScope(): void + { + $this->container->bindExecution('svc', fn() => new \stdClass()); + $this->container->extend('svc', function (\stdClass $o) { + $o->extended = true; + + return $o; + }); + + $this->container->runInContext('request', [], function () { + $svc = $this->container->get('svc'); + + $this->assertTrue($svc->extended); + $this->assertSame($svc, $this->container->get('svc')); + }); + } + + public function testRebindingAScopedServiceDropsItsLiveInstance(): void + { + $this->container->bindExecution('svc', fn() => (object) ['v' => 1]); + + $this->container->runInContext('request', [], function () { + $this->assertSame(1, $this->container->get('svc')->v); + + $this->container->bindExecution('svc', fn() => (object) ['v' => 2]); + + $this->assertSame(2, $this->container->get('svc')->v); + }); + } + + public function testFlushEndsOpenContextsAndRunsTheirEndingListenersThenClearsListeners(): void + { + $ended = []; + $this->container->contextEnding(function (ExecutionContext $c) use (&$ended) { + $ended[] = $c->type(); + }); + + $this->container->beginContext('request'); + $this->container->beginContext('worker'); + $suspended = $this->container->beginContext('console'); + $this->container->suspendContext($suspended); + + $this->container->flush(); + + $this->assertEqualsCanonicalizing(['request', 'worker', 'console'], $ended); + $this->assertNull($this->container->executionContext()); + + // Listeners were cleared by the flush. + $this->container->runInContext('request', [], fn() => null); + $this->assertCount(3, $ended); + } + + public function testFlushEndsContextsBeforeBindingsAreCleared(): void + { + $this->container->bindExecution('svc', fn() => new \stdClass()); + + $resolved = null; + $this->container->contextEnding(function () use (&$resolved) { + $resolved = $this->container->get('svc'); + }); + + $this->container->beginContext('request'); + $this->container->flush(); + + $this->assertInstanceOf(\stdClass::class, $resolved); + } + + public function testFlushStillClearsEverythingWhenAnEndingListenerThrows(): void + { + $this->container->contextEnding(function () { + throw new \RuntimeException('listener'); + }); + $this->container->beginContext('request'); + + try { + $this->container->flush(); + $this->fail('expected exception'); + } catch (\RuntimeException $e) { + $this->assertSame('listener', $e->getMessage()); + } + + $this->assertNull($this->container->executionContext()); + $this->container->runInContext('request', [], fn() => null); + } + + public function testEndedContextCannotBeResumed(): void + { + $context = $this->container->beginContext('request'); + $this->container->endContext($context); + + $this->container->resumeContext($context); + + $this->assertNull($this->container->executionContext()); + } + + public function testContextEndedByFlushCannotBeResumed(): void + { + $context = $this->container->beginContext('request'); + $this->container->suspendContext($context); + $this->container->flush(); + + $this->container->resumeContext($context); + + $this->assertNull($this->container->executionContext()); + } + + public function testResumingACurrentContextDoesNotDuplicateIt(): void + { + $context = $this->container->beginContext('request'); + + $this->container->resumeContext($context); + $this->container->endContext($context); + + $this->assertNull($this->container->executionContext()); + } + + public function testResumeRestoresMetadataStampedAfterTheContextWasHandedOut(): void + { + $original = $this->container->beginContext('request'); + $this->container->stampContext(['tenant' => 'acme']); + $this->container->suspendContext($original); + + $this->container->resumeContext($original); + + $this->assertSame('acme', $this->container->executionContext()->get('tenant')); + + $this->container->endContext($original); + } + + public function testEndingASuspendedContextShowsListenersItsLatestMetadata(): void + { + $seen = null; + $this->container->contextEnding(function (ExecutionContext $c) use (&$seen) { + $seen = $c->get('tenant'); + }); + + $original = $this->container->beginContext('request'); + $this->container->stampContext(['tenant' => 'acme']); + $this->container->suspendContext($original); + + $this->container->endContext($original); + + $this->assertSame('acme', $seen); + } + + // ---------------------------------------------------------------- service / facade accessor + + public function testExecutionServiceReadsAndExtendsTheActiveContext(): void + { + $service = new ExecutionService($this->container); + + $this->assertFalse($service->running()); + $this->assertNull($service->id()); + $this->assertSame('fallback', $service->get('tenant', 'fallback')); + $this->assertSame([], $service->all()); + + $service->within('console', ['command' => 'migrate'], function () use ($service) { + $this->assertTrue($service->running()); + $this->assertSame('console', $service->type()); + $this->assertSame('migrate', $service->get('command')); + + $id = $service->id(); + $service->stamp(['tenant' => 9]); + + $this->assertSame($id, $service->id()); + $this->assertSame(9, $service->get('tenant')); + }); + + $this->assertFalse($service->running()); + } + + public function testExecutionHelperReturnsTheExecutionService(): void + { + Container::setInstance($this->container); + $this->container->singleton('execution', fn() => new ExecutionService($this->container)); + + try { + $this->assertInstanceOf(ExecutionService::class, execution()); + + $this->container->runInContext('console', ['command' => 'x'], function () { + $this->assertSame('console', execution()->type()); + }); + } finally { + Container::forgetInstance(); + } + } + + public function testExecutionServiceReturnsAnExplicitNullInsteadOfTheDefault(): void + { + $service = new ExecutionService($this->container); + + $this->container->runInContext('request', ['tenant' => null], function () use ($service) { + $this->assertNull($service->get('tenant', 'fallback')); + $this->assertSame('fallback', $service->get('missing', 'fallback')); + }); + } + + // ---------------------------------------------------------------- HTTP lifecycle + + private function application(): Application + { + $app = (new ReflectionClass(Application::class))->newInstanceWithoutConstructor(); + $app->reportDeferredExceptionsUsing(fn() => null); + + return $app; + } + + private function respondingRouter(callable $inside): Router + { + $router = $this->createMock(Router::class); + $router->method('resolve')->willReturnCallback(function () use ($inside) { + $inside(); + + $response = $this->getMockBuilder(Response::class) + ->disableOriginalConstructor() + ->onlyMethods(['prepare', 'send']) + ->getMock(); + $response->method('prepare')->willReturnSelf(); + $response->method('send')->willReturnSelf(); + + return $response; + }); + + return $router; + } + + public function testDispatchRunsTheRequestInsideAContextThatEndsOnTerminate(): void + { + $app = $this->application(); + $seen = null; + + $app->router = $this->respondingRouter(function () use ($app, &$seen) { + $seen = $app->executionContext(); + }); + + $result = $app->dispatch(new Request()); + + $this->assertSame('request', $seen->type()); + $this->assertNull($app->executionContext(), 'the context is suspended while the response is sent'); + + $ended = null; + $app->contextEnding(function (ExecutionContext $c) use (&$ended) { + $ended = $c; + }); + + $result->terminate(); + + $this->assertSame($seen->id(), $ended->id()); + $this->assertNull($app->executionContext()); + } + + public function testTerminatingCallbacksStillSeeTheRequestContext(): void + { + $app = $this->application(); + $app->router = $this->respondingRouter(fn() => null); + + $during = null; + $app->terminating(function () use ($app, &$during) { + $during = $app->executionContext(); + }); + + $app->dispatch(new Request())->terminate(); + + $this->assertNotNull($during); + $this->assertSame('request', $during->type()); + } + + public function testExecutionScopedServicesDoNotLeakBetweenSequentialRequests(): void + { + $app = $this->application(); + $app->bindExecution('tenant.state', fn() => new \stdClass()); + + $instances = []; + $contexts = []; + + $app->router = $this->respondingRouter(function () use ($app, &$instances, &$contexts) { + $state = $app->get('tenant.state'); + $state->touched = ($state->touched ?? 0) + 1; + + $instances[] = $state; + $contexts[] = $app->executionContext()->id(); + }); + + $app->dispatch(new Request())->terminate(); + $app->dispatch(new Request())->terminate(); + + $this->assertCount(2, $instances); + $this->assertNotSame($instances[0], $instances[1]); + $this->assertSame(1, $instances[1]->touched, 'the second request must start from a clean instance'); + $this->assertNotSame($contexts[0], $contexts[1]); + } + + public function testScopedInstanceSurvivesFromHandlerToTerminatingCallback(): void + { + $app = $this->application(); + $app->bindExecution('state', fn() => new \stdClass()); + + $fromHandler = null; + $fromTerminating = null; + + $app->router = $this->respondingRouter(function () use ($app, &$fromHandler) { + $fromHandler = $app->get('state'); + }); + $app->terminating(function () use ($app, &$fromTerminating) { + $fromTerminating = $app->get('state'); + }); + + $app->dispatch(new Request())->terminate(); + + $this->assertSame($fromHandler, $fromTerminating); + } + + public function testContextEndsWhenTheHandlerThrows(): void + { + $app = $this->application(); + $ended = 0; + $app->contextEnding(function () use (&$ended) { + $ended++; + }); + + $router = $this->createMock(Router::class); + $router->method('resolve')->willThrowException(new \RuntimeException('handler failed')); + $app->router = $router; + + try { + $app->dispatch(new Request()); + $this->fail('expected exception'); + } catch (\RuntimeException) { + } + + $this->assertSame(1, $ended); + $this->assertNull($app->executionContext()); + } + + public function testRequestMetadataComesFromTheRequest(): void + { + $app = $this->application(); + $request = new Request(); + $request->headers->set('X-Request-Id', 'abc-123'); + + $captured = null; + $app->router = $this->respondingRouter(function () use ($app, &$captured) { + $captured = $app->executionContext(); + }); + + $app->dispatch($request)->terminate(); + + $this->assertSame('abc-123', $captured->get('request_id')); + } + + public function testMetadataStampedDuringTheRequestIsVisibleAtTermination(): void + { + $app = $this->application(); + + $app->router = $this->respondingRouter(function () use ($app) { + $app->stampContext(['tenant' => 'acme']); + }); + + $inTerminating = null; + $app->terminating(function () use ($app, &$inTerminating) { + $inTerminating = $app->executionContext()->get('tenant'); + }); + + $inEnding = null; + $app->contextEnding(function (ExecutionContext $c) use (&$inEnding) { + $inEnding = $c->get('tenant'); + }, 'request'); + + $app->dispatch(new Request())->terminate(); + + $this->assertSame('acme', $inTerminating); + $this->assertSame('acme', $inEnding); + } + + public function testRequestMetadataDoesNotIncludeThePersonalClientAddress(): void + { + $app = $this->application(); + $captured = null; + + $app->router = $this->respondingRouter(function () use ($app, &$captured) { + $captured = $app->executionContext(); + }); + + $_SERVER['REMOTE_ADDR'] = '203.0.113.9'; + + try { + $app->dispatch(new Request())->terminate(); + } finally { + unset($_SERVER['REMOTE_ADDR']); + } + + $this->assertFalse($captured->has('ip')); + $this->assertArrayNotHasKey('ip', $captured->all()); + } + + // ---------------------------------------------------------------- console lifecycle + + public function testConsoleCommandRunsInAConsoleContextThatEndsAfterDeferredCleanup(): void + { + $app = $this->application(); + $app->bindExecution('job.state', fn() => new \stdClass()); + + $order = []; + $seen = null; + $cleanupSawContext = null; + + $console = new class($app) extends Console { + protected function bootstrapCommands(): void {} + }; + $console->setCatchExceptions(false); + + $command = new Command('ctx:test'); + $command->setCode(function () use ($app, &$order, &$seen, &$cleanupSawContext): int { + $seen = $app->executionContext(); + $app->get('job.state'); + $app->defer(function () use ($app, &$order, &$cleanupSawContext) { + $order[] = 'defer'; + $cleanupSawContext = $app->executionContext(); + }); + + return Command::SUCCESS; + }); + $console->addCommand($command); + + $app->contextEnding(function () use (&$order) { + $order[] = 'context-end'; + }); + + $console->run(new ArrayInput(['command' => 'ctx:test']), new NullOutput()); + + $this->assertSame('console', $seen->type()); + $this->assertSame('ctx:test', $seen->get('command')); + $this->assertSame(['defer', 'context-end'], $order); + $this->assertSame($seen->id(), $cleanupSawContext->id()); + $this->assertNull($app->executionContext()); + } +} diff --git a/tests/Logger/ExecutionContextLoggingTest.php b/tests/Logger/ExecutionContextLoggingTest.php new file mode 100644 index 00000000..9606280e --- /dev/null +++ b/tests/Logger/ExecutionContextLoggingTest.php @@ -0,0 +1,99 @@ +container = new Container(); + Container::setInstance($this->container); + + $this->handler = new TestHandler(); + $handler = $this->handler; + + $this->log = new class($handler) extends LoggerService { + public function __construct(private TestHandler $handler) {} + + protected function reader(): Logger + { + return new Logger('test', [$this->handler]); + } + }; + } + + protected function tearDown(): void + { + Container::forgetInstance(); + } + + public function testRecordsOutsideAContextAreUnchanged(): void + { + $this->log->info('plain', ['a' => 1]); + + $this->assertSame(['a' => 1], $this->handler->getRecords()[0]->context); + } + + public function testRecordsCarryTheActiveExecutionContext(): void + { + $this->container->runInContext('request', ['path' => '/pay', 'tenant' => 5], function () { + $id = $this->container->executionContext()->id(); + + $this->log->error('failed', ['order' => 10]); + + $context = $this->handler->getRecords()[0]->context; + + $this->assertSame(10, $context['order']); + $this->assertSame($id, $context['execution']['id']); + $this->assertSame('request', $context['execution']['type']); + $this->assertSame(5, $context['execution']['metadata']['tenant']); + }); + } + + public function testMetadataAddedMidRequestShowsUpInLaterRecords(): void + { + $this->container->runInContext('request', [], function () { + $this->log->info('before'); + $this->container->stampContext(['tenant' => 'acme']); + $this->log->info('after'); + }); + + [$before, $after] = $this->handler->getRecords(); + + $this->assertArrayNotHasKey('tenant', $before->context['execution']['metadata']); + $this->assertSame('acme', $after->context['execution']['metadata']['tenant']); + } + + public function testNextExecutionDoesNotInheritThePreviousOnesMetadata(): void + { + $this->container->runInContext('request', ['tenant' => 'one'], fn() => $this->log->info('first')); + $this->container->runInContext('request', [], fn() => $this->log->info('second')); + + [$first, $second] = $this->handler->getRecords(); + + $this->assertSame('one', $first->context['execution']['metadata']['tenant']); + $this->assertArrayNotHasKey('tenant', $second->context['execution']['metadata']); + $this->assertNotSame($first->context['execution']['id'], $second->context['execution']['id']); + } + + public function testCallerSuppliedExecutionKeyWins(): void + { + $this->container->runInContext('request', [], function () { + $this->log->info('custom', ['execution' => 'mine']); + }); + + $this->assertSame('mine', $this->handler->getRecords()[0]->context['execution']); + } +}