Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.6.1] - 2026-08-30

### Fixed
- **Essential modules never reached a queued job.** `HttpPipeline` passed its
essentials into `OnDemandLoader`; `WorkerLoop` built its loader with none, and
`Kernel::materialize()` had no way to hand them over. So a module the project
declared app-wide in `proj.json` `"essentials"` was app-wide for requests and
**absent from every job** — and for an essential that rebinds a port per scope
(tenancy rebinding `DatabasePort`) the failure is silent rather than loud: the
binding still resolves, just to the wrong connection. The worker now registers
essentials into every job container and seeds their domains into the job's
graph, so their transitive `requires[]` come with them — the same two steps
`LoadStage` performs for a request. A job whose class the manifest does not
know now also gets a container rather than the bare `CoreContainer`, since
"essential" means every unit of work; an application that declares no
essentials keeps its exact previous behaviour, including that fallback.
The class→domain mapping both surfaces need moved to
`DependencyGraphCalculator::domainsFor()`; a private copy in each pipeline is
how they drifted apart in the first place.
- **`APP_DEBUG` meant two different things in one file.** `ErrorStage::isDebug()`
parsed the value with `FILTER_VALIDATE_BOOL` while `publicError()` compared it
`=== 'true'`. With `APP_DEBUG=1` the HTML debug page — stack trace and source
excerpt — was served to anything sending `Accept: text/html`, while every JSON
response still masked its message as "An internal error occurred.". One flag,
two behaviours, and the more revealing of the two was the one that engaged.
There is now one `isDebug()`, used by both; `FILTER_VALIDATE_BOOL` is the
surviving parse because it is what every other kernel flag uses
(`HttpPipeline::flag()`), so `1`, `on`, `yes` and `true` mean the same thing
throughout. It also now reads through `env()` rather than `$_ENV`/`getenv()`:
the environment loader deliberately skips `putenv()`, so `getenv()` is not the
source of truth for a `.env` value. **Note the direction of the change** — with
`APP_DEBUG=1` the JSON path now reveals exception messages, which is what the
flag was asked for; `APP_DEBUG` unset or falsy masks them exactly as before.

## [1.6.0] - 2026-08-29

### Added
Expand Down
5 changes: 5 additions & 0 deletions src/Kernel/Kernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,11 @@ private function materialize(RuntimeMode $mode): void
$errorPipeline,
$this->workerPipe,
$this->workerSecret ?? (string) (env('JOB_SIGNING_SECRET') ?: ''),
// Essentials reach the worker for the same reason they reach HTTP: a
// job is a request with no HTTP in front of it, and a module the
// project declared app-wide should not quietly stop existing at the
// queue boundary.
essentialModules: $this->essentialModules,
);

// Configuration compiled by CompileConfigManifestStage during build().
Expand Down
37 changes: 37 additions & 0 deletions src/Kernel/Loading/DependencyGraphCalculator.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,43 @@ public function __construct(
private readonly array $manifest
) {}

/**
* The solves domains of a set of provider classes, read from a compiled
* service manifest.
*
* Essential modules are configured as CLASSES but seeded into a graph as
* DOMAINS, and resolving them through the graph (rather than only registering
* them standalone) is what brings their transitive requires[] with them — an
* essential like Tenancy gets its Database dependency bound instead of failing
* with an unbound contract.
*
* Lives here, not on a pipeline, because BOTH the HTTP and worker surfaces
* need the same mapping and a private copy in each is how they drifted apart
* in the first place. A class absent from the manifest (not in withModules)
* is skipped — OnDemandLoader still registers it standalone.
*
* @param array{services?: array<string, array<string, mixed>>} $manifest
* @param list<class-string> $moduleClasses
* @return list<string>
*/
public static function domainsFor(array $manifest, array $moduleClasses): array
{
if ($moduleClasses === []) {
return [];
}

$wanted = array_flip($moduleClasses);
$domains = [];

foreach ($manifest['services'] ?? [] as $domain => $entry) {
if (isset($wanted[$entry['module'] ?? ''])) {
$domains[] = $domain;
}
}

return $domains;
}

/**
* Resolve the dependency graph for a service, optionally seeding extra
* module domains the matched route declared in its own requires[].
Expand Down
33 changes: 5 additions & 28 deletions src/Kernel/Pipelines/Http/HttpPipeline.php
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,11 @@ private function buildStages(): array
new SecurityStage($this->gateway),
...$this->resolveHook('after.security'),
new ResolveStage($this->matcher, self::flag('ROUTE_METHOD_NOT_ALLOWED', false)),
new LoadStage($this->calculator, $this->loader, self::essentialDomains($manifest, $this->essentialModules)),
new LoadStage(
$this->calculator,
$this->loader,
DependencyGraphCalculator::domainsFor($manifest, $this->essentialModules),
),
...$this->resolveHook('after.load'),
new RouteFilterStage($this->filters, $this->core),
new ExecuteStage(),
Expand Down Expand Up @@ -263,31 +267,4 @@ private static function policy(): string
};
}

/**
* Map the essential module classes to their solves domains via the compiled
* service manifest, so LoadStage can seed them (and thus their transitive
* requires) into every request's dependency graph. An essential not present
* in the manifest (not in withModules) is skipped — OnDemandLoader still
* registers it standalone, preserving the previous behaviour.
*
* @param array{services?: array<string, array<string, mixed>>} $manifest
* @param list<class-string> $essentialModules
* @return list<string>
*/
private static function essentialDomains(array $manifest, array $essentialModules): array
{
if ($essentialModules === []) {
return [];
}

$wanted = array_flip($essentialModules);
$domains = [];
foreach ($manifest['services'] ?? [] as $domain => $entry) {
if (isset($wanted[$entry['module'] ?? ''])) {
$domains[] = $domain;
}
}

return $domains;
}
}
27 changes: 25 additions & 2 deletions src/Kernel/Pipelines/Http/Stages/ErrorStage.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,32 @@ private function buildErrorResponse(\Throwable $e, Request $request): Response
return Response::json(['error' => $body], $status);
}

/**
* Whether the application is in debug mode — the ONE place this is decided.
*
* It used to be decided twice, and differently: this method parsed the value
* with FILTER_VALIDATE_BOOL while publicError() compared it `=== 'true'`. So
* APP_DEBUG=1 enabled the HTML debug page — stack trace and source excerpt —
* for anything sending `Accept: text/html`, while every JSON response still
* masked its message as "An internal error occurred.". One flag, two
* behaviours, and the more revealing of the two was the one that turned on.
*
* FILTER_VALIDATE_BOOL is the surviving parse because it is what every other
* kernel flag uses (see HttpPipeline::flag()), so `1`, `on`, `yes` and `true`
* all mean the same thing across the kernel.
*
* Read through env(), not $_ENV/getenv(): the environment loader deliberately
* skips putenv(), so getenv() is not the source of truth for a .env value.
*/
private function isDebug(): bool
{
return filter_var($_ENV['APP_DEBUG'] ?? getenv('APP_DEBUG') ?: 'false', FILTER_VALIDATE_BOOL);
$value = \function_exists('env') ? env('APP_DEBUG') : ($_ENV['APP_DEBUG'] ?? null);

if ($value === null || $value === '') {
return false;
}

return filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? false;
}

/** API surface convention — same prefix the CSRF layer exempts. */
Expand Down Expand Up @@ -101,7 +124,7 @@ private function resolveHttpCode(\Throwable $e): int
/** @return array<string, mixed> */
private function publicError(\Throwable $e): array
{
$debug = ($_ENV['APP_DEBUG'] ?? getenv('APP_DEBUG') ?? 'false') === 'true';
$debug = $this->isDebug();

if ($e instanceof ValidationException) {
return ['code' => 'validation_failed', 'message' => $e->getMessage(), 'fields' => $e->errors];
Expand Down
59 changes: 51 additions & 8 deletions src/Kernel/Pipelines/Worker/WorkerLoop.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ final class WorkerLoop
private ?DependencyGraphCalculator $calculator = null;
private ?OnDemandLoader $loader = null;

/**
* Solves domains of $essentialModules, mapped through the service manifest
* once (alongside the calculator) rather than per job.
*
* @var list<string>
*/
private array $essentialDomains = [];

/**
* Per-job retry strategies, built once from the compiled manifest.
*
Expand Down Expand Up @@ -73,6 +81,20 @@ public function __construct(
private readonly string $signingSecret = '',
/** Backoff applied by release() when a job declares no retry of its own. */
private readonly RetryStrategyContract $retry = new ExponentialRetryStrategy(),
/**
* Provider classes marked ESSENTIAL — registered into every job's
* container regardless of the job's own dependency graph, exactly as
* HttpPipeline does for every request.
*
* Without this the two surfaces disagreed about what "essential" means:
* a module declared essential in proj.json was app-wide for requests and
* absent from every job. For an essential that rebinds a port per scope
* (tenancy rebinding DatabasePort) the failure is silent rather than
* loud — the binding still resolves, just to the wrong connection.
*
* @var list<class-string<\AlfacodeTeam\PhpServicePlatform\Kernel\Contracts\ModuleContract>>
*/
private readonly array $essentialModules = [],
) {
}

Expand Down Expand Up @@ -401,28 +423,49 @@ private function process(JobPayload $payload): JobResult
}

/**
* Build a request-scoped ModuleContainer for the job's domain.
* Returns null if the job's domain cannot be resolved — falls back to CoreContainer.
* Build a request-scoped ModuleContainer for the job's domain, plus every
* essential module — the same world an HTTP request is given.
*
* Returns null only when there is nothing to build: no job domain AND no
* essentials. That preserves the historical CoreContainer fallback for an
* application that declares no essentials, while an application that does
* declare them gets them on a job whose class the manifest does not know —
* which is exactly the case "essential" is supposed to cover.
*
* Worker jobs always receive a guest Identity (no HTTP request context).
*/
private function resolveContainer(string $jobName): ?ModuleContainer
{
// First real job: load the manifests now (not at construction time).
$this->jobManifest ??= $this->loadManifest('job-manifest.php', []);
$this->calculator ??= new DependencyGraphCalculator(
$this->loadManifest('service-manifest.php', ['services' => []])
);
$this->loader ??= new OnDemandLoader($this->core);

if ($this->calculator === null) {
$manifest = $this->loadManifest('service-manifest.php', ['services' => []]);
$this->calculator = new DependencyGraphCalculator($manifest);
// Essentials are configured as CLASSES and seeded as DOMAINS; the
// mapping needs the service manifest, so it is resolved once here
// rather than on every job.
$this->essentialDomains = DependencyGraphCalculator::domainsFor($manifest, $this->essentialModules);
}

// Passing the essentials to the LOADER registers them; seeding their
// DOMAINS into the graph below additionally brings their transitive
// requires[]. LoadStage does both, for the same reason.
$this->loader ??= new OnDemandLoader($this->core, $this->essentialModules);

$entry = $this->jobManifest[$jobName] ?? null;
$domain = $entry['solves'] ?? null;

if ($domain === null) {
$targets = $domain !== null
? [$domain, ...$this->essentialDomains]
: $this->essentialDomains;

if ($targets === []) {
return null;
}

try {
$graph = $this->calculator->resolve($domain);
$graph = $this->calculator->resolve($targets[0], array_slice($targets, 1));
return $this->loader->loadWithIdentity($graph, null); // guest Identity for worker context
} catch (\Throwable) {
return null; // fall back to CoreContainer if graph resolution fails
Expand Down
Loading