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
78 changes: 78 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,84 @@ All notable changes to LaraFly are documented here. This project uses CalVer (`Y

## [Unreleased]

Two things the same application found, one on a developer's machine and one on a shared Kafka topic. Delete a
`#[Component]`, forget to recompile, and neither of the two commands that exist to repair the compiled cache
could run any more. And a `<topic>.DLT` that LaraFly and PyFly both publish to held two kinds of record:
PyFly's, which says why it died and where it came from, and LaraFly's, which was raw bytes with no provenance
at all. Nothing here changes what a served request does.

### BREAKING

- **`packages/eda-kafka` — `KafkaConsumerClient::deadLetter()` takes the whole record and a reason, and
`deadLetterRaw()` is gone.** The port had two dead-letter methods — one taking an `EventEnvelope` for an
exhausted retry, one taking raw bytes for a poison record — and both wrote a record with **no headers at
all**. That is what a shared dead-letter topic cannot afford. dworkers runs LaraFly and PyFly against the
same topics, and PyFly has stamped `x-dlt-reason`, `x-dlt-source-topic` and `x-dlt-source-offset` on every
record it dead-letters since `v26.09.06`, so whoever drained a `<topic>.DLT` could not tell a LaraFly poison
record from a replayed payload, and the offset needed to go back and look at the original was not there.
`grep -rn 'x-dlt' packages/` returned nothing. The two methods are now one —
`deadLetter(ReceivedEnvelope $received, string $dltTopic, string $reason)` — because the record already
carries everything the provenance needs (the bytes or the envelope, the topic it was read from, and the
broker handle its offset hangs off), and only the consumer knows *why*. **Migration:** an application that
implements `KafkaConsumerClient` itself — a test double, or a client wrapping a different Kafka extension —
replaces its two methods with the one. Nothing else: `KafkaEventConsumer` is the only caller, and an
application that merely *uses* the Kafka adapter sees no API change, only three headers it did not have.

### Added

- **`packages/eda-kafka` — every record LaraFly dead-letters to a `<topic>.DLT` carries `x-dlt-reason`,
`x-dlt-source-topic` and `x-dlt-source-offset`.** The names and the reason's spelling are PyFly's, exactly:
the short class name of the throw that refused the bytes (`SerializationException`, `JsonException`, …,
which is PyFly's `type(exc).__name__`), or `RetriesExhausted` for a record that decoded perfectly well and
then ran out of retries — a case PyFly does not have, because it deliberately does not dead-letter handler
failures. The source topic is the one the record was **consumed** from, not the DLT and not the envelope's
declared destination. A header the record cannot answer is left out rather than written empty, because an
empty offset reads as an offset. Written with `producev()` (ext-rdkafka ≥ 3.1, well below the
`librdkafka >= 1.5.3` this package already suggests); the poison path still re-produces the **raw bytes
verbatim**, so a fixed producer can replay them byte for byte. RabbitMQ needs none of this and gets none:
the broker itself stamps `x-death` on everything its `x-dead-letter-exchange` routes, and the framework
never republishes a message there to have an opinion about.
- **`packages/context` — `Firefly\Context\Scan\AppScan::repairing()`**, true while `firefly:cache` *or*
`firefly:clear` is the running command. Deliberately wider than `regenerating()` and kept separate from it:
`regenerating()` also decides whether a capability reads its compiled artifact or re-scans, which is a
question `firefly:clear` — which reads nothing and writes nothing — has no business answering.
- **`packages/context` — `Firefly\Context\Definition\StaleDefinitionReport`**, the append-only record of
what a repair boot dropped, bound as a container singleton by `FireflyAutoConfigureServiceProvider` and read
by `firefly:cache`. Shaped like `ConditionEvaluationReport`, for the same reason: reporting through a logger
would put a `psr/log` edge on the boot engine, which neither `firefly/context` nor `firefly/container`
carries. `BeanDefinitionRegistry` takes it, and the drop flag, as constructor arguments that both default to
the previous behaviour — an existing `new BeanDefinitionRegistry` filters nothing and reports nothing.

### Fixed

- **`packages/context` + `packages/container` — deleting a `#[Component]` no longer makes `firefly:cache` and
`firefly:clear` unrunnable, and `firefly:cache` says which entry it dropped.** `EagerSingletonsPass` has
skipped a definition whose class no longer exists since `26.09.1`, and it was not enough, because the guard
protects the one pass it is written in. The deleted class was still in the manifest `ContainerRegistrar`
received, so `wireInterfaces()` bound the interface it implemented — and tagged it as an implementation — to
a class autoloading could not find: the throw came out of `make()` for a perfectly live abstract, while
resolving a bean nobody had touched, and named a file the developer had already deleted. Three more places
read a class straight off the same manifest with nothing between them and `make()`:
`RegisterBeanPostProcessorsPass` (phase 700, *before* eager singletons), `InfrastructureStartPass`, and
`RegisterEventListenersPass`, whose listener closure throws on the first dispatch rather than at boot.
`composer dump-autoload` could not recover it either — `package:discover` boots the application too — so the
only way out was `rm bootstrap/cache/firefly/*.php`, then `composer dump-autoload`, then `firefly:cache`, in
that order: a three-step incantation a developer simply had to know.

The check now lives at the one door every definition comes through. While `firefly:cache` or `firefly:clear`
is the running command, `BeanDefinitionRegistry::add()` drops a definition whose class cannot be found and
records it, so the registrar and all four passes see a manifest that agrees with what is on disk, and
`firefly:cache` prints one extra line naming what it dropped:
`firefly:cache — skipped 1 stale manifest entry naming a class that no longer exists: App\Security\ControlPlaneJwksProvider`.
The manifest it then writes no longer mentions the class, so the next run is an ordinary clean one.

**Under every other command nothing changes**, and that asymmetry is deliberate rather than cautious: a
class that has gone missing in a process about to serve traffic is not a stale cache but a broken deployment
— a truncated artifact, a classmap built from a different tree — and dropping the definition there would
hand the application an interface quietly rebound to whichever implementation happened to survive, with
nothing said anywhere. Only a MISSING class is ever tolerated: a class that exists and cannot be constructed
still fails fast, in a repair command as much as anywhere else.

## [26.09.4] - 2026-09-23

The gaps a second real application had to work around, closed in the framework instead. Every entry below
Expand Down
27 changes: 27 additions & 0 deletions book/src-es/13-cli-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,11 @@ final class CacheCommand extends Command
$dir,
));

$this->reportStaleEntries();

return self::SUCCESS;
}
// …
}
```

Expand Down Expand Up @@ -296,6 +299,30 @@ Seguro de ejecutar en cualquier momento: el siguiente arranque simplemente recur

---

## Cuando la caché nombra una clase que borraste

Ambos comandos anteriores tienen que arrancar la aplicación antes de poder hacer su trabajo — `firefly:cache` no puede escribir los manifiestos sin arrancar primero la aplicación cuyos manifiestos son, y `firefly:clear` no puede borrar un directorio por el que no ha arrancado. Esa circularidad se vuelve incómoda en cuanto la caché está *equivocada*, y la forma habitual de equivocarla es borrar un `#[Component]` y no recompilar: `component.php` sigue nombrando la clase, y ningún autoloader la encuentra.

Ese estado no se podía recuperar con ningún comando. La clase borrada seguía en el manifiesto que leía `ContainerRegistrar`, así que `wireInterfaces()` ligaba la interfaz que implementaba — y la etiquetaba como implementación — a una clase que no estaba. El fallo aparecía por tanto lejos de su causa: `Target class [App\Security\ControlPlaneJwksProvider] does not exist` salía al resolver un bean que nadie había tocado y que se limita a inyectar esa interfaz. `EagerSingletonsPass` omite una definición cuya clase ya no existe, y no servía de nada, porque la excepción nunca se lanzaba sobre la definición borrada. `composer dump-autoload` tampoco ayudaba — su `package:discover` arranca la aplicación también. La recuperación era borrar `bootstrap/cache/firefly/*.php` a mano, luego `composer dump-autoload`, y luego `firefly:cache`, en ese orden.

`Firefly\Context\Scan\AppScan::repairing()` es la costura que lo cierra: mientras el comando en ejecución sea `firefly:cache` o `firefly:clear`, `BeanDefinitionRegistry` — la única puerta por la que entra toda definición — descarta una definición cuya clase no se encuentra, de modo que ni el registrar, ni el paso de bean post-processors, ni el de ciclo de vida, ni el de listeners llegan a verla. `firefly:cache` dice entonces qué entradas descartó:

```
php artisan firefly:cache
```

```
firefly:cache — wrote 14 manifest(s) + 3 proxy(ies) to bootstrap/cache/firefly
firefly:cache — skipped 1 stale manifest entry naming a class that no longer exists: App\Security\ControlPlaneJwksProvider
```

El manifiesto que escribe ya no menciona la clase, así que la segunda ejecución es una ejecución limpia normal y la línea desaparece.

!!! warning "Solo esos dos comandos descartan algo"
Bajo cualquier otro comando el manifiesto se toma tal cual, y una clase que ha desaparecido sigue deteniendo el arranque con el error del propio contenedor. Ahí esa es la respuesta correcta: una clase ausente en un proceso que está a punto de servir tráfico no es una caché rancia, es un despliegue roto — un artefacto truncado, un classmap construido desde otro árbol — y descartar la definición le entregaría a la aplicación una interfaz religada en silencio a la implementación que sobreviviera. Una respuesta equivocada de la que nadie se entera es peor que el fallo de arranque. Y solo se tolera una clase AUSENTE: una clase que existe y no se puede construir sigue fallando rápido, tanto en un comando de reparación como en cualquier otro sitio.

---

## Actuator-sobre-CLI: `firefly:about`, `firefly:routes`, `firefly:health`, `firefly:metrics`

El Capítulo 11 construyó una superficie de gestión alcanzable sobre HTTP. Estos cuatro comandos renderizan los **mismos** endpoints en el terminal, en-proceso — no se hace ninguna petición HTTP, y ninguno de ellos reimplementa lógica alguna del actuator:
Expand Down
27 changes: 27 additions & 0 deletions book/src/13-cli-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,11 @@ final class CacheCommand extends Command
$dir,
));

$this->reportStaleEntries();

return self::SUCCESS;
}
// …
}
```

Expand Down Expand Up @@ -296,6 +299,30 @@ Safe to run at any time: the very next boot simply falls back to the in-process

---

## When the cache names a class you deleted

Both commands above have to boot the application before they can do their work — `firefly:cache` cannot write the manifests without first booting the application whose manifests they are, and `firefly:clear` cannot delete a directory it has not booted past. That circularity turns uncomfortable the moment the cache is *wrong*, and the ordinary way to make it wrong is to delete a `#[Component]` and not recompile: `component.php` still names the class, and no autoloader can find it.

That state used to be unrecoverable by any single command. The deleted class was still in the manifest `ContainerRegistrar` read, so `wireInterfaces()` bound the interface it implemented — and tagged it as an implementation — to a class that was not there. The failure therefore surfaced nowhere near its cause: `Target class [App\Security\ControlPlaneJwksProvider] does not exist` came out of resolving a bean nobody had touched, one that merely injects that interface. `EagerSingletonsPass` skips a definition whose class is gone, and it did not help, because the throw was never raised on the deleted definition. `composer dump-autoload` could not help either — its `package:discover` boots the application too. The recovery was to delete `bootstrap/cache/firefly/*.php` by hand, then `composer dump-autoload`, then `firefly:cache`, in that order.

`Firefly\Context\Scan\AppScan::repairing()` is the seam that closes it: while `firefly:cache` or `firefly:clear` is the running command, `BeanDefinitionRegistry` — the one door every definition enters through — drops a definition whose class cannot be found, so the registrar, the bean-post-processor pass, the lifecycle pass and the event-listener pass never see it. `firefly:cache` then names what it dropped:

```
php artisan firefly:cache
```

```
firefly:cache — wrote 14 manifest(s) + 3 proxy(ies) to bootstrap/cache/firefly
firefly:cache — skipped 1 stale manifest entry naming a class that no longer exists: App\Security\ControlPlaneJwksProvider
```

The manifest it writes no longer mentions the class, so the second run is an ordinary clean one and the line goes away.

!!! warning "Only those two commands drop anything"
Under every other command the manifest is trusted exactly as it was, and a class that has gone missing still stops the boot with the container's own error. That is the right answer there: a missing class in a process about to serve traffic is not a stale cache, it is a broken deployment — a truncated artifact, a classmap built from a different tree — and dropping the definition would hand the application an interface quietly rebound to whichever implementation survived. A wrong answer nobody is told about is worse than the boot failure. And only a MISSING class is ever tolerated: a class that exists and cannot be constructed still fails fast, in a repair command as much as anywhere else.

---

## Actuator-over-CLI: `firefly:about`, `firefly:routes`, `firefly:health`, `firefly:metrics`

Chapter 11 built a management surface reachable over HTTP. These four commands render the **same** endpoints at the terminal, in-process — no HTTP request is made, and none of them reimplement any actuator logic:
Expand Down
29 changes: 29 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,35 @@ rather than run unprotected.
Compiling is still worth it — reflection-free boot is the point of `firefly:cache` — but it is now an optimisation
rather than a correctness requirement.

### When the cache names a class you deleted

Deleting a `#[Component]` and forgetting to recompile leaves `bootstrap/cache/firefly/component.php` naming a class
no autoloader can find. That used to be unrecoverable by any single command: the deleted class was still in the
manifest the container registrar read, so the interface it implemented was bound to a class that was not there, and
`firefly:cache` and `firefly:clear` — each of which has to boot the application before it can rewrite or delete the
manifest — both died with `Target class [...] does not exist`, pointing at a file you had already deleted, from
inside a bean you had not touched.

Those two commands now drop a manifest entry whose class cannot be found, and `firefly:cache` says which one:

```
php artisan firefly:cache
```

```
firefly:cache — wrote 14 manifest(s) + 3 proxy(ies) to bootstrap/cache/firefly
firefly:cache — skipped 1 stale manifest entry naming a class that no longer exists: App\Security\ControlPlaneJwksProvider
```

The manifest it writes no longer mentions the class, so the next run is an ordinary clean one and the line goes away.

**Only these two commands drop anything, and that is deliberate.** Under every other command the manifest is trusted
exactly as before, and a missing class still stops the boot. A class that has gone missing in a process about to
serve traffic is not a stale cache — it is a broken deployment, a truncated artifact or a classmap built from a
different tree — and quietly dropping the definition there would rebind the interface to whichever implementation
happened to survive, with nothing said anywhere. Only a MISSING class is ever tolerated: a class that exists and
cannot be constructed still fails fast, in a repair command as much as anywhere else.

## `firefly:clear`

```
Expand Down
Loading
Loading