fix(context,cli) + feat(eda-kafka)!: the two wave-6 asks LaraFly owns - #3
Merged
Merged
Conversation
added 2 commits
September 24, 2026 12:45
…he two commands that repair it, and firefly:cache says what it dropped EagerSingletonsPass has skipped a definition whose class no longer exists since 26.09.1, and it was not enough: 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 therefore 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 at 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, because 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. The check now lives at the one door every definition comes through. While firefly:cache or firefly:clear is the running command — AppScan::repairing(), deliberately wider than regenerating() and kept separate from it — BeanDefinitionRegistry::add() drops a definition whose class cannot be found and records it in a StaleDefinitionReport, so the registrar and all four passes see a manifest that agrees with what is on disk. CacheCommand prints one line naming what was dropped; the manifest it 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 the more important half. A class that has gone missing in a process about to serve traffic is a broken deployment, not a stale cache, and dropping the definition there would hand the application an interface quietly rebound to whichever implementation survived, with nothing said anywhere. Only a MISSING class is ever tolerated: a class that exists and cannot be constructed still fails fast. The registry's new constructor arguments both default to the previous behaviour, so an existing `new BeanDefinitionRegistry` filters nothing and reports nothing. Tests. StaleDefinitionFilterTest pins the door itself, including the default that keeps a stale definition under any other command. StaleManifestWiringTest runs the four consumer paths the way boot runs them and carries two controls: a boot that is not repairing still throws "Target class [...] does not exist", and a class that exists and cannot be built still fails fast in a repair boot. StaleManifestRecoveryTest is the end-to-end case — a real source tree whose manifests are compiled by a SUBPROCESS (a scanner class_exists()es, and PHP never forgets a declared class, so an in-process compile would make the test pass for the wrong reason), the #[Primary] implementation then deleted from disk, firefly:cache exiting 0 and naming the entry, firefly:clear booting past the same manifest, and the same manifest still failing loudly under migrate. Docs. docs/cli.md and chapter 13 of both manuscripts carry the recovery, the console line it prints and the reason only these two commands drop anything; the CacheCommand listing under the provenance guard is re-quoted from the file.
…ree provenance headers PyFly writes BREAKING: KafkaConsumerClient::deadLetter() takes the whole ReceivedEnvelope and a reason; deadLetterRaw() is gone. LaraFly wrote a dead-letter record with no headers at all, and PyFly writes three. dworkers runs both frameworks against shared topics, so one <topic>.DLT 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 whatsoever. Whoever drained that topic 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 to be read. grep -rn 'x-dlt' packages/ returned nothing. The port had two dead-letter methods — one taking an EventEnvelope for an exhausted retry, one taking raw bytes for a poison record — and neither was handed the provenance it would have needed. They are now one method taking the record itself, because the record already carries the bytes or the envelope, the topic it was read from, and the broker handle its offset hangs off; the only thing this layer owes the DLT is WHY, which is the third argument. RdKafkaConsumerClient stamps x-dlt-reason, x-dlt-source-topic and x-dlt-source-offset, spelled exactly as PyFly spells them (src/pyfly/eda/adapters/kafka.py), because a topic both frameworks publish to is only readable if one `kcat -C -t <topic>.DLT -f '%h'` explains every record on it. The reason is the throw's short class name — 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. A header the record cannot answer is left out rather than written empty: an x-dlt-source-offset of '' reads as an offset. producev() rather than produce(), which cannot carry headers; it has been in ext-rdkafka since 3.1, well below the librdkafka >= 1.5.3 this package already suggests. RabbitMQ needs none of this and gets none. The broker itself stamps x-death — source queue, exchange, reason and count — on everything its x-dead-letter-exchange routes, and the framework never republishes a message there to have an opinion about. Postgres keeps the row. Tests. dltHeaders() and dltPayload() are public and static for the same reason received() is: they are the halves of deadLetter() that owe nothing to ext-rdkafka, so the header set, the omission of a header the record cannot answer, and the raw-bytes-verbatim rule are all unit-asserted with no extension installed. KafkaEventConsumerTest pins both reasons through the consumer. The ext+broker-gated KafkaRoundTripTest reads the three headers back off the real DLT record, which is the only place producev() is exercised at all.
… made its own escape-hatch claim true The guard's comment has always said firefly:cache and firefly:clear are the two commands its skip restores, and the claim was only ever true of the pass it is written in: a deleted #[Component] that implemented an interface breaks the boot in ContainerRegistrar instead, and the throw comes out of a bean that still exists. Say where the check actually lives now, and what this one is still for.
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.
Closes the two items the sixth wave's findings leave open on LaraFly's side. They are unrelated defects found by the same application, and they ship together because the CHANGELOG habit accumulates under
[Unreleased]and one tag cuts every package.1. L2b — a manifest naming a deleted class stops the two commands that repair it
What a developer hits today
Delete a
#[Component], forget to recompile, andbootstrap/cache/firefly/component.phpstill names a class no autoloader can find. That state was unrecoverable by any single command.EagerSingletonsPasshas 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 manifestContainerRegistrarreceived, sowireInterfaces()bound the interface it implemented — and tagged it as an implementation — to a class that was not there. The throw came out ofmake()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():RegisterBeanPostProcessorsPassInfrastructureStartPassRegisterEventListenersPasscomposer dump-autoloadcould not recover it either (package:discoverboots the application too), so the only way out wasrm bootstrap/cache/firefly/*.php, thencomposer dump-autoload, thenfirefly:cache, in that order.The fix
The check moves to the one door every definition comes through. While
firefly:cacheorfirefly:clearis the running command —AppScan::repairing(), deliberately wider thanregenerating()and kept separate from it —BeanDefinitionRegistry::add()drops a definition whose class cannot be found and records it in aStaleDefinitionReport. The registrar and all four passes then see a manifest that agrees with what is on disk.firefly:cacheprints one extra line:The manifest it writes no longer mentions the class, so the next run is an ordinary clean one and the line goes away.
What does NOT change, and why
Under every other command the manifest is trusted exactly as before, and a missing class still stops the boot with the container's own error. 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, 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. A wrong answer nobody is told about is worse than the boot failure being removed.
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.
BeanDefinitionRegistry's new constructor arguments both default to the previous behaviour, so an existingnew BeanDefinitionRegistryfilters nothing and reports nothing. Nopsr/logedge is added: the report is shaped likeConditionEvaluationReportand bound on the container, which is what letsfirefly/cliread it.Tests
packages/context/tests/Definition/StaleDefinitionFilterTest.php— the door itself, including the default that keeps a stale definition under any other command, and that a dropped entry never reachestoComponentManifest().packages/context/tests/Pass/StaleManifestWiringTest.php— the four consumer paths run the way boot runs them, plus two controls: a boot that is not repairing still throwsTarget class [...] does not exist, and a class that exists and cannot be built still fails fast inside a repair boot.packages/cli/tests/Cache/StaleManifestRecoveryTest.php— end to end. A real source tree whose manifests are compiled by a subprocess (every scannerclass_exists()es, and PHP never forgets a declared class, so an in-process compile would make the test pass for entirely the wrong reason), the#[Primary]implementation then deleted from disk;firefly:cacheexits 0 and names the entry, the rewritten manifest no longer carries it,firefly:clearboots past the same manifest, and the same manifest still fails loudly undermigrate.packages/context/tests/Scan/AppScanTest.php—repairing()covers both commands andregenerating()still covers onlyfirefly:cache.2. L13 — LaraFly wrote a dead-letter record with no headers, and PyFly writes three
BREAKING
KafkaConsumerClient::deadLetter()now takes the wholeReceivedEnvelopeand a reason;deadLetterRaw()is gone.dworkers runs LaraFly and PyFly against shared topics, so one
<topic>.DLTheld 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 whatsoever. Whoever drained that topic 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 port had two dead-letter methods and neither was handed the provenance it would have needed. They are now one, because the record already carries the bytes or the envelope, the topic it was read from, and the broker handle its offset hangs off — the only thing the consumer layer owes the DLT is why.
Migration: an application implementing
KafkaConsumerClientitself (a test double, or a client over a different Kafka extension) replaces its two methods with the one. An application that merely uses the adapter sees no API change, only three headers it did not have.The headers
x-dlt-reasontype(exc).__name__), orRetriesExhaustedfor a record that decoded and then ran out of retriesx-dlt-source-topicx-dlt-source-offsetSpelled exactly as PyFly spells them (
src/pyfly/eda/adapters/kafka.py), because a topic both frameworks publish to is only readable if onekcat -C -t <topic>.DLT -f '%h'explains every record on it. A header the record cannot answer is left out rather than written empty — anx-dlt-source-offsetof''reads as an offset.producev()rather thanproduce(), which cannot carry headers; it has been in ext-rdkafka since 3.1, well below thelibrdkafka >= 1.5.3this package already suggests. The poison path still re-produces the raw bytes verbatim.RabbitMQ needs none of this and gets none. The broker itself stamps
x-death— source queue, exchange, reason and count — on everything itsx-dead-letter-exchangeroutes, and the framework never republishes a message there to have an opinion about. Postgres keeps the row.Tests
dltHeaders()anddltPayload()are public and static for the same reasonreceived()is: they are the halves ofdeadLetter()that owe nothing to ext-rdkafka, so the header set, the omission of an unanswerable header and the raw-bytes-verbatim rule are all unit-asserted with no extension installed.KafkaEventConsumerTestpins both reasons through the consumer. The ext+broker-gatedKafkaRoundTripTestreads the three headers back off the real DLT record, which is the only placeproducev()is exercised at all.Gates, locally
composer check(pint, phpstan level max, pest, deptrac),composer mono-validate,.venv-docs/bin/mkdocs build --strict,python3 book/build/verify_code.py,bash scripts/check-no-sensitive-tracked.sh— all green: 3805 passed, 6 skipped, 0 phpstan errors, 0 deptrac violations.Docs
docs/cli.mdand chapter 13 of both manuscripts carry the stale-manifest recovery, the console line and the reason only two commands drop anything.docs/modules/eda-brokers.mdand thefirefly/eda-kafkafront page carry the three headers and why RabbitMQ does not get them. TheCacheCommandlisting under the provenance guard is re-quoted from the file, andtests/DocsProseIsRealTest.php's console-line canary moves 7 → 10 for the three new blocks.